| 1 | /** |
| 2 | * Intermediate representation between the rendered DOM and a `.pptx`. |
| 3 | * `RawSnapshot` is what the in-page walker returns and stays close to the DOM |
| 4 | * (code inside `page.evaluate` is unreachable from unit tests). `SlideIr` is |
| 5 | * what the normalizer produces and stays close to what `pptxgenjs` wants. |
| 6 | * Every decision lives in `normalize.ts` between the two, as pure functions |
| 7 | * over plain JSON, testable without a browser. |
| 8 | */ |
| 9 | |
| 10 | /** |
| 11 | * All geometry is in canvas pixels: `canvasWidth` by `round(canvasWidth / aspectRatio)`, |
| 12 | * origin at the top-left of the slide (not of the print page). `exportSlides` |
| 13 | * sizes the PowerPoint slide as `width / 96` by `height / 96` inches, so one |
| 14 | * CSS pixel is exactly 1/96 inch. 1px = 1pt does not hold in general. |
| 15 | */ |
| 16 | export const EMU_PER_PX = 9525 // 914400 EMU per inch / 96 px per inch |
| 17 | export const INCHES_PER_PX = 1 / 96 |
| 18 | |
| 19 | /** Points per pixel (72 pt per inch against 96 px per inch), for font size, letter spacing and line height. */ |
| 20 | export const PT_PER_PX = 0.75 // 72 / 96 |
| 21 | |
| 22 | export interface Rect { |
| 23 | x: number |
| 24 | y: number |
| 25 | w: number |
| 26 | h: number |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * The computed-style subset the walker captures, as raw CSS strings; parsing |
| 31 | * happens on the Node side. Interned: `RawNode.style` indexes `RawSnapshot.styles`. |
| 32 | */ |
| 33 | export interface RawStyle { |
| 34 | display: string |
| 35 | position: string |
| 36 | zIndex: string |
| 37 | visibility: string |
| 38 | opacity: string |
| 39 | color: string |
| 40 | backgroundColor: string |
| 41 | backgroundImage: string |
| 42 | fontFamily: string |
| 43 | fontSize: string |
| 44 | fontWeight: string |
| 45 | fontStyle: string |
| 46 | textAlign: string |
| 47 | textDecorationLine: string |
| 48 | textTransform: string |
| 49 | letterSpacing: string |
| 50 | lineHeight: string |
| 51 | whiteSpace: string |
| 52 | paddingLeft: string |
| 53 | paddingRight: string |
| 54 | borderTopWidth: string |
| 55 | borderTopStyle: string |
| 56 | borderTopColor: string |
| 57 | borderRightWidth: string |
| 58 | borderRightStyle: string |
| 59 | borderRightColor: string |
| 60 | borderBottomWidth: string |
| 61 | borderBottomStyle: string |
| 62 | borderBottomColor: string |
| 63 | borderLeftWidth: string |
| 64 | borderLeftStyle: string |
| 65 | borderLeftColor: string |
| 66 | borderTopLeftRadius: string |
| 67 | boxShadow: string |
| 68 | filter: string |
| 69 | backdropFilter: string |
| 70 | mixBlendMode: string |
| 71 | clipPath: string |
| 72 | transform: string |
| 73 | writingMode: string |
| 74 | webkitBackgroundClip: string |
| 75 | overflow: string |
| 76 | top: string |
| 77 | right: string |
| 78 | bottom: string |
| 79 | left: string |
| 80 | width: string |
| 81 | height: string |
| 82 | } |
| 83 | |
| 84 | export interface RawNode { |
| 85 | /** Index into `RawSnapshot.nodes`, and the value of `data-slidev-export-id`. */ |
| 86 | id: number |
| 87 | /** Parent's `id`, or -1 for the slide container itself. */ |
| 88 | parent: number |
| 89 | /** Upper-case tag name, or `#text` for a text node. */ |
| 90 | tag: string |
| 91 | /** Index into `RawSnapshot.styles`; -1 for text nodes, which have no style. */ |
| 92 | style: number |
| 93 | /** Relative to the slide container, not to the print page. */ |
| 94 | rect: Rect |
| 95 | /** Text nodes only: `Range.getClientRects()`, slide-relative. Kept as the raw list so the normalizer can count line boxes; a text box positioned from the element rect lands offset and re-wraps. */ |
| 96 | glyphRects?: Rect[] |
| 97 | /** Text nodes only: raw `textContent`, before `text-transform` is applied. */ |
| 98 | text?: string |
| 99 | /** One rect per line box for a wrapped inline element. Backgrounds and borders paint per line fragment, not over the `getBoundingClientRect()` union. */ |
| 100 | fragments?: Rect[] |
| 101 | /** `IMG` only. */ |
| 102 | src?: string |
| 103 | /** `IMG` only. */ |
| 104 | alt?: string |
| 105 | /** `A` only, resolved absolute. */ |
| 106 | href?: string |
| 107 | /** Root of a rendered formula. KaTeX sets it as dozens of positioned spans in its own metric fonts; walked as text the layout falls apart, so it is rasterized whole. */ |
| 108 | isMath?: boolean |
| 109 | /** An `SVG` carrying `<foreignObject>` (as Mermaid renders), so its labels are HTML. No PowerPoint renderer draws that; it has to be rasterized. */ |
| 110 | hasForeignObject?: boolean |
| 111 | /** Reached through `el.shadowRoot`. Mermaid diagrams live in one. */ |
| 112 | fromShadowRoot?: boolean |
| 113 | /** Opacity compounded from every ancestor, present only when below 1. DrawingML has no group opacity, so a wrapper's transparency is folded into each descendant's colors. */ |
| 114 | opacity?: number |
| 115 | /** A `::marker` list bullet, which is a pseudo-element and has no text node. */ |
| 116 | marker?: string |
| 117 | /** Page coordinates. A `::before` or `::after` has no element of its own, so a screenshot clips the page instead of targeting a locator. */ |
| 118 | pageRect?: Rect |
| 119 | } |
| 120 | |
| 121 | export interface RawSlide { |
| 122 | /** 1-based deck slide number, parsed from the container id `003-02`. */ |
| 123 | no: number |
| 124 | /** 0-based click step within that slide. */ |
| 125 | clickIndex: number |
| 126 | /** The container's exact DOM id, e.g. `003-02`. A selector rebuilt from `no` matches every click step of the slide, so screenshots target this instead. */ |
| 127 | containerId: string |
| 128 | size: { w: number, h: number } |
| 129 | /** The slide's own background color, as a raw CSS string. */ |
| 130 | background?: string |
| 131 | nodes: RawNode[] |
| 132 | } |
| 133 | |
| 134 | export interface RawSnapshot { |
| 135 | slides: RawSlide[] |
| 136 | styles: RawStyle[] |
| 137 | /** |
| 138 | * CSS font stack as written, mapped to the family the browser actually resolved, |
| 139 | * measured by comparing rendered widths in the page. `document.fonts.check()` |
| 140 | * cannot be used: it returns true for families that do not exist. |
| 141 | */ |
| 142 | fontResolution: Record<string, string> |
| 143 | /** Pseudo-elements that paint something but take part in flow, so their box cannot be resolved from computed style. Reported so the loss is logged rather than silent. */ |
| 144 | unplaceablePseudos: string[] |
| 145 | } |
| 146 | |
| 147 | /** Straight (non-premultiplied) alpha, 0 to 1. */ |
| 148 | export interface Rgba { |
| 149 | r: number |
| 150 | g: number |
| 151 | b: number |
| 152 | a: number |
| 153 | } |
| 154 | |
| 155 | export interface Border { |
| 156 | width: number |
| 157 | color: Rgba |
| 158 | style: 'solid' | 'dashed' | 'dotted' |
| 159 | } |
| 160 | |
| 161 | interface IrBase { |
| 162 | rect: Rect |
| 163 | /** The `RawNode.id` this came from. Only for diagnostics. */ |
| 164 | sourceId: number |
| 165 | } |
| 166 | |
| 167 | export interface IrBox extends IrBase { |
| 168 | kind: 'box' |
| 169 | fill?: Rgba |
| 170 | /** |
| 171 | * Top, right, bottom, left; a side is undefined when it has no visible border. |
| 172 | * `pptxgenjs` gives a shape a single uniform `line`, so the builder emits |
| 173 | * each differing side as its own filled rectangle. |
| 174 | */ |
| 175 | borders?: [Border?, Border?, Border?, Border?] |
| 176 | /** Pixels. The builder converts; `rectRadius` is in inches despite its docs. */ |
| 177 | radius?: number |
| 178 | shadow?: { blur: number, offset: number, angle: number, color: Rgba } |
| 179 | } |
| 180 | |
| 181 | export interface IrRun { |
| 182 | /** Already `text-transform`ed. */ |
| 183 | text: string |
| 184 | /** Pixels. */ |
| 185 | fontSize: number |
| 186 | /** Already resolved to a family that exists on this machine. */ |
| 187 | fontFamily: string |
| 188 | bold?: boolean |
| 189 | italic?: boolean |
| 190 | underline?: boolean |
| 191 | /** |
| 192 | * How the underline is drawn. A `border-bottom` on inline text is an |
| 193 | * underline, and DrawingML can dash one, so it does not need a shape. |
| 194 | */ |
| 195 | underlineStyle?: 'sng' | 'dash' | 'dotted' |
| 196 | strike?: boolean |
| 197 | color?: Rgba |
| 198 | /** Pixels. */ |
| 199 | letterSpacing?: number |
| 200 | link?: string |
| 201 | /** Emit a line break before this run. From `<br>`. */ |
| 202 | breakBefore?: boolean |
| 203 | /** This run ends a paragraph. */ |
| 204 | endsParagraph?: boolean |
| 205 | } |
| 206 | |
| 207 | export interface IrText extends IrBase { |
| 208 | /** `rect` is glyph bounds, not the element box. See `RawNode.glyphRects`. */ |
| 209 | kind: 'text' |
| 210 | elementRect: Rect |
| 211 | /** Distinct line-box tops. One means the box must not be allowed to re-wrap. */ |
| 212 | lineCount: number |
| 213 | align: 'left' | 'center' | 'right' | 'justify' |
| 214 | /** Defaults to top, since the box is measured from the glyphs; middle is for chip labels, whose box is the decoration rather than the ink. */ |
| 215 | valign?: 'top' | 'middle' |
| 216 | /** Pixels, with `normal` already resolved to a number. */ |
| 217 | lineHeight: number |
| 218 | runs: IrRun[] |
| 219 | } |
| 220 | |
| 221 | export interface IrImage extends IrBase { |
| 222 | kind: 'image' |
| 223 | /** A `data:` URI. Always raster; SVG is routed to `IrRaster` instead. */ |
| 224 | data: string |
| 225 | alt?: string |
| 226 | link?: string |
| 227 | /** |
| 228 | * The visible region when the slide edge cuts the image short: draw at full |
| 229 | * size, then crop to `rect`, as the browser does; squeezing into the clipped |
| 230 | * box would compress it. All values are CSS pixels: `w`/`h` the full display |
| 231 | * size, `x`/`y` the offset from its top-left corner to `rect`. |
| 232 | */ |
| 233 | crop?: { x: number, y: number, w: number, h: number } |
| 234 | } |
| 235 | |
| 236 | export type RasterReason |
| 237 | = | 'svg' |
| 238 | | 'math' |
| 239 | | 'foreign-object' |
| 240 | | 'canvas' |
| 241 | | 'media' |
| 242 | | 'iframe' |
| 243 | | 'background-image' |
| 244 | | 'background-clip-text' |
| 245 | | 'filter' |
| 246 | | 'backdrop-filter' |
| 247 | | 'mix-blend-mode' |
| 248 | | 'clip-path' |
| 249 | | 'transform' |
| 250 | | 'writing-mode' |
| 251 | |
| 252 | export interface IrRaster extends IrBase { |
| 253 | kind: 'raster' |
| 254 | /** A PNG `data:` URI, filled in by the capture phase. */ |
| 255 | data: string |
| 256 | reason: RasterReason |
| 257 | /** Capture with everything else on the page hidden. `locator.screenshot()` clips the page to the element's box rather than isolating the element, so overlapping content would land in the picture and be drawn twice. */ |
| 258 | isolate: boolean |
| 259 | /** Also hide the element's own children while capturing. Correct only when they are walked and redrawn as shapes; hiding a leaf's children (e.g. an `<svg>`) removes its artwork from the picture. */ |
| 260 | hideDescendants: boolean |
| 261 | } |
| 262 | |
| 263 | export type IrNode = IrBox | IrText | IrImage | IrRaster |
| 264 | |
| 265 | export interface SlideIr { |
| 266 | /** 1-based deck slide number. */ |
| 267 | no: number |
| 268 | clickIndex: number |
| 269 | /** The rendered container's exact DOM id, for targeting a screenshot. */ |
| 270 | containerId: string |
| 271 | size: { w: number, h: number } |
| 272 | background?: Rgba |
| 273 | /** Paint order is array order: an inline decoration must be emitted before the text it sits behind. */ |
| 274 | nodes: IrNode[] |
| 275 | note?: string |
| 276 | /** |
| 277 | * When set, `nodes` is ignored and the slide is written as a single background |
| 278 | * picture, exactly as `--format pptx` does. Set when the walk failed, the |
| 279 | * slide lost all of its text, or so much rasterized that vectorizing bought nothing. |
| 280 | */ |
| 281 | fallbackPng?: string |
| 282 | fallbackReason?: string |
| 283 | } |
| 284 |