diff --git a/.changeset/pptx-render-and-font-fidelity.md b/.changeset/pptx-render-and-font-fidelity.md new file mode 100644 index 0000000..cd6996b --- /dev/null +++ b/.changeset/pptx-render-and-font-fidelity.md @@ -0,0 +1,10 @@ +--- +"@textcortex/slidewise": patch +--- + +Fix several PPTX import-rendering fidelity gaps surfaced by real-world decks: + +- **Picture/SVG fills on shapes** — shapes whose fill is an `` (the modern Office "icon" pattern, including dual PNG+SVG blips) now render their artwork. Previously these `custGeom` icons (globes, stars, grid textures, brand marks) imported with no fill and showed blank. The image is painted clipped to the shape's silhouette, or as a box-filling background for rect/rounded/circle shapes. +- **Empty picture placeholders** — empty picture placeholders inherited from the slide layout no longer leak onto the slide as grey "Insert Picture" prompt boxes. Picture placeholders the slide actually hosts now inherit their rounded geometry and fill from the layout/master so they render as the template intends. +- **Embedded fonts (EOT / MicroType-Express)** — embedded `.fntdata` fonts now decode to browser-valid TTFs. Two bugs were fixed: composite glyphs that carried `WE_HAVE_INSTRUCTIONS` on a non-first component produced a malformed `glyf` table, and format-12 `cmap` subtables shipped a non-zero `language` field — both caused the browser's font sanitizer (OTS) to reject the whole font and fall back to a system typeface. +- **Weight-named font families** — weight-named embedded families (e.g. "Montserrat Bold", "Montserrat Semi-Bold") are now also aliased to their base family at the matching numeric weight, so bold/semi-bold text bound to the base family renders with the real embedded face instead of a synthetic bold. diff --git a/packages/slidewise/src/components/editor/ElementView.tsx b/packages/slidewise/src/components/editor/ElementView.tsx index 738f5c1..1a4125b 100644 --- a/packages/slidewise/src/components/editor/ElementView.tsx +++ b/packages/slidewise/src/components/editor/ElementView.tsx @@ -681,9 +681,23 @@ function linearGradientVector(deg: number): { }; } +/** + * A shape's `fill` may be a picture/SVG fill the importer captured from a + * PPTX `` (modern Office icons), stored as `url("data:…")` / + * `url(https://…)`. Pull the bare URL out so it can be painted as an + * `` (vector shapes) or `background-image` (rect/circle). Gradients + * and solid colours return undefined — they paint via the normal path. + */ +function imageFillUrlOf(fill: string | undefined): string | undefined { + if (!fill) return undefined; + const m = /^\s*url\((['"]?)(.*?)\1\)\s*$/.exec(fill); + return m ? m[2] : undefined; +} + function ShapeView({ el }: { el: ShapeElement }) { const stroke = el.stroke ?? "transparent"; const sw = el.strokeWidth ?? 0; + const imageFill = imageFillUrlOf(el.fill); // Accept either `strokeDash` (raw OOXML, set by the importer) or // `dashType` (typed enum, set by AI-authored / host-supplied decks). // Raw wins when both are set — it preserves PPTX intent exactly. @@ -691,11 +705,52 @@ function ShapeView({ el }: { el: ShapeElement }) { const effect = effectStyle(el.shadow, el.glow, "filter"); // SVG `fill=` can't take a CSS gradient string, so vector shapes need a // paint server. Build it once and reuse for path + polygon renderers. - const gradId = `sw-grad-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + const uid = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const gradId = `sw-grad-${uid}`; const { paint, def } = svgGradientPaint(el.fill, gradId); // Custom vector path (PPTX ) takes precedence over the preset // kind — the path coordinates already encode the actual silhouette. if (el.path) { + // Picture/SVG fill: paint the image clipped to the silhouette rather + // than handing the renderer an `url(...)` it can't use as an SVG paint. + if (imageFill) { + const clipId = `sw-clip-${uid}`; + return ( + + + + + + + + {sw ? ( + + ) : null} + + ); + } return ( ); } + // PPTX `` fills the box edge-to-edge; mirror that + // with a non-repeating, box-sized background image. Use the + // `background-image` longhand (not the `background` shorthand, which would + // reset background-size back to its initial value). + const fillStyle: React.CSSProperties = imageFill + ? { + backgroundImage: el.fill, + backgroundSize: "100% 100%", + backgroundRepeat: "no-repeat", + backgroundPosition: "center", + } + : { background: el.fill }; if (el.shape === "rect" || el.shape === "rounded") { return (
)", () => { + it("paints a custGeom path image fill via a clipped , not an SVG path fill", () => { + const el: ShapeElement = { + ...base, + id: "icon", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + fill: `url("${DATA_URL}")`, + path: { d: "M 0 0 L 100 0 L 100 100 Z", viewW: 100, viewH: 100 }, + }; + const { container } = render(); + + // The art renders as an clipped to the silhouette. + const image = container.querySelector("image"); + expect(image).toBeTruthy(); + expect(image?.getAttribute("href")).toBe(DATA_URL); + expect(image?.getAttribute("clip-path") ?? "").toMatch(/^url\(#/); + + const clip = container.querySelector("clipPath path"); + expect(clip?.getAttribute("d")).toBe("M 0 0 L 100 0 L 100 100 Z"); + + // The url() must never be handed to an SVG path fill (invalid → blank). + const fillPath = container.querySelector("path[fill^='url(\"']"); + expect(fillPath).toBeNull(); + }); + + it("draws the silhouette stroke on top of the image when the shape is stroked", () => { + const el: ShapeElement = { + ...base, + id: "icon-stroked", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + fill: `url("${DATA_URL}")`, + stroke: "#FF0000", + strokeWidth: 3, + path: { d: "M 0 0 L 100 0 L 100 100 Z", viewW: 100, viewH: 100 }, + }; + const { container } = render(); + const strokePath = container.querySelector("path[stroke='#FF0000']"); + expect(strokePath).toBeTruthy(); + expect(strokePath?.getAttribute("fill")).toBe("none"); + }); + + it("fills a rect shape edge-to-edge via a non-repeating background image", () => { + const el: ShapeElement = { + ...base, + id: "panel", + type: "shape", + x: 0, + y: 0, + w: 300, + h: 100, + shape: "rect", + fill: `url("${DATA_URL}")`, + }; + const { container } = render(); + const div = container.querySelector("div"); + const style = div?.getAttribute("style") ?? ""; + expect(style).toContain(DATA_URL); + expect(style).toMatch(/background-size:\s*100%\s+100%/); + expect(style).toMatch(/background-repeat:\s*no-repeat/); + }); +}); diff --git a/packages/slidewise/src/lib/__tests__/fonts-weight.test.ts b/packages/slidewise/src/lib/__tests__/fonts-weight.test.ts new file mode 100644 index 0000000..4244774 --- /dev/null +++ b/packages/slidewise/src/lib/__tests__/fonts-weight.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { splitFamilyWeight } from "../fonts"; + +describe("splitFamilyWeight", () => { + it("maps weight-named families to base + numeric weight", () => { + expect(splitFamilyWeight("Montserrat Bold")).toEqual({ base: "Montserrat", weight: 700 }); + expect(splitFamilyWeight("Montserrat Semi-Bold")).toEqual({ base: "Montserrat", weight: 600 }); + expect(splitFamilyWeight("Montserrat SemiBold")).toEqual({ base: "Montserrat", weight: 600 }); + expect(splitFamilyWeight("Open Sans Light")).toEqual({ base: "Open Sans", weight: 300 }); + expect(splitFamilyWeight("Roboto Medium")).toEqual({ base: "Roboto", weight: 500 }); + expect(splitFamilyWeight("Inter Extra Bold")).toEqual({ base: "Inter", weight: 800 }); + expect(splitFamilyWeight("Inter Black")).toEqual({ base: "Inter", weight: 900 }); + expect(splitFamilyWeight("Lato Thin")).toEqual({ base: "Lato", weight: 100 }); + }); + + it("prefers the most specific suffix (Semi/Extra Bold over Bold)", () => { + // A bare "...Bold" rule must not strip "Semi-Bold" down to "...Semi". + expect(splitFamilyWeight("Helvetica Neue Semibold")).toEqual({ + base: "Helvetica Neue", + weight: 600, + }); + }); + + it("returns null when there is no weight suffix", () => { + expect(splitFamilyWeight("DM Serif Display")).toBeNull(); + expect(splitFamilyWeight("Montserrat")).toBeNull(); + expect(splitFamilyWeight("Arial")).toBeNull(); + // Must not strip a weight word that is the whole name / leaves nothing. + expect(splitFamilyWeight("Bold")).toBeNull(); + }); +}); diff --git a/packages/slidewise/src/lib/fonts.ts b/packages/slidewise/src/lib/fonts.ts index 433196c..c9aea94 100644 --- a/packages/slidewise/src/lib/fonts.ts +++ b/packages/slidewise/src/lib/fonts.ts @@ -210,11 +210,11 @@ function escapeCss(s: string): string { * 1. `Deck.webFonts` — per-deck overrides, AI-authored decks ship these. * 2. `fontRegistry` — host-wide brand fonts the platform owns. * 3. **Decoded `Deck.fonts`** — embedded `.fntdata` payloads the importer - * pulled from `ppt/fonts/`. When the EOT is uncompressed (or uses an - * MTX sub-method we can decode), we synthesise a `data:font/ttf;…` - * URL on the fly. Brand-embedded fonts that use MTX glyph compression - * can't be decoded yet and are skipped — `fontRegistry` is the - * documented fallback for those cases. + * pulled from `ppt/fonts/`. These are EOT, usually MicroType-Express + * (MTX) compressed; `decodeEot` decompresses them and reconstructs the + * `glyf` table into a browser-valid TTF, surfaced as a `data:font/ttf;…` + * URL. A font that still can't be decoded (truncated / unsupported + * variant) is skipped and `fontRegistry` is the documented fallback. * * The first source to claim a `(family, weight, italic)` tuple wins. */ @@ -267,10 +267,11 @@ export function resolveWebFonts( /** * Convert a `Deck.fonts` entry (raw `.fntdata` from `ppt/fonts/`) into a - * `WebFontAsset` the editor can render. Returns `null` when the EOT is - * MTX-compressed (we have a partial decoder; the brand-font glyph encoder - * isn't done yet — see `./fonts/mtx.ts`) so callers can move on to the - * registry / system-font fallback chain. + * `WebFontAsset` the editor can render. `decodeEot` handles uncompressed and + * MicroType-Express (MTX) compressed EOT, reconstructing the `glyf` table. + * Returns `null` only when the payload still can't be decoded (truncated / + * unsupported variant) so callers can fall back to the registry / system + * font chain. * * The returned asset uses a `data:font/ttf;base64,...` URL so the resulting * `@font-face` is fully self-contained — no CDN, no network request. @@ -330,17 +331,71 @@ export function fontAssetToWebFont(asset: FontAsset): WebFontAsset | null { } } +/** + * Weight-name suffixes a font family can carry, longest/most-specific first + * so "Semi Bold" / "Extra Bold" win over a bare "Bold" match. Used to alias a + * weight-named embedded family (e.g. "Montserrat Bold") to its base family at + * the matching numeric weight, so text that asks for the base family in bold + * ("Montserrat" + b) renders with the REAL bold face the deck shipped instead + * of a synthetic (faux) bold of the regular face. + */ +const WEIGHT_SUFFIXES: Array<[RegExp, number]> = [ + [/[\s-]?thin$/i, 100], + [/[\s-]?(?:extra|ultra)[\s-]?light$/i, 200], + [/[\s-]?light$/i, 300], + [/[\s-]?regular$/i, 400], + [/[\s-]?normal$/i, 400], + [/[\s-]?medium$/i, 500], + [/[\s-]?(?:semi|demi)[\s-]?bold$/i, 600], + [/[\s-]?(?:extra|ultra)[\s-]?bold$/i, 800], + [/[\s-]?(?:black|heavy)$/i, 900], + [/[\s-]?bold$/i, 700], +]; + +/** + * Split a trailing weight word off a family name. "Montserrat Semi-Bold" → + * { base: "Montserrat", weight: 600 }. Returns null when the family carries no + * recognised weight suffix (e.g. "DM Serif Display"). + */ +export function splitFamilyWeight( + family: string +): { base: string; weight: number } | null { + for (const [re, weight] of WEIGHT_SUFFIXES) { + if (re.test(family)) { + const base = family.replace(re, "").trim(); + if (base.length) return { base, weight }; + } + } + return null; +} + /** * Bulk-convert `Deck.fonts` → `WebFontAsset[]` filtering out the entries * we couldn't decode. Safe to call eagerly inside a `useMemo` because * decoding a 200KB font runs in single-digit ms. + * + * For each weight-named family we ALSO emit an alias under the base family at + * the matching numeric weight (Montserrat Bold → Montserrat / 700), so a run + * that asks for "Montserrat" in bold binds to the real bold face rather than + * synthesising one. The original family is kept too, so runs that name the + * weight-variant directly still resolve. */ export function decodeDeckEmbeddedFonts(deck: Deck): WebFontAsset[] { if (!deck.fonts || !deck.fonts.length) return []; const out: WebFontAsset[] = []; for (const asset of deck.fonts) { const web = fontAssetToWebFont(asset); - if (web) out.push(web); + if (!web) continue; + out.push(web); + const split = splitFamilyWeight(web.family); + if (split) { + out.push({ + family: split.base, + src: web.src, + weight: split.weight, + italic: web.italic, + }); + } } return out; } diff --git a/packages/slidewise/src/lib/fonts/__tests__/cmap-language.test.ts b/packages/slidewise/src/lib/fonts/__tests__/cmap-language.test.ts new file mode 100644 index 0000000..b68ec53 --- /dev/null +++ b/packages/slidewise/src/lib/fonts/__tests__/cmap-language.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { reconstructTrueType } from "../ctf-glyf"; + +// PowerPoint-embedded faces (e.g. DM Serif Display) ship format-12 cmap +// subtables with a non-zero `language` on the Windows/Unicode platform. The +// browser sanitizer (OTS) rejects the whole font for this, even though +// fontTools tolerates it. reconstructTrueType must zero `language` on +// non-Macintosh subtables so the decoded font actually loads in a browser. + +function u16(v: number): number[] { + return [(v >> 8) & 0xff, v & 0xff]; +} +function u32(v: number): number[] { + return [(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff]; +} + +function buildCtfWithBadCmap(): Uint8Array { + // glyf: single empty glyph (numContours = 0). + const glyf = [0x00, 0x00]; + const maxp = new Array(32).fill(0); + maxp[4] = 0; maxp[5] = 1; // numGlyphs = 1 + const head = new Array(54).fill(0); + + // cmap: one format-12 subtable, platform 3 / enc 10, language = 1007. + const subOff = 12; // 4 (header) + 8 (one encoding record) + const subtable = [ + ...u16(12), ...u16(0), // format 12, reserved + ...u32(28), // length (16 header + 12 group) + ...u32(1007), // language (BAD — must become 0) + ...u32(1), // numGroups + ...u32(65), ...u32(65), ...u32(0), // one group: 'A' -> glyph 0 + ]; + const cmap = [ + ...u16(0), ...u16(1), // version, numTables + ...u16(3), ...u16(10), ...u32(subOff), // encoding record + ...subtable, + ]; + + const tables: Array<{ tag: string; data: number[] }> = [ + { tag: "cmap", data: cmap }, + { tag: "maxp", data: maxp }, + { tag: "head", data: head }, + { tag: "glyf", data: glyf }, + ]; + const dirSize = 12 + tables.length * 16; + let off = dirSize; + const header = [...u16(0x0001), ...u16(0x0000), ...u16(tables.length), ...u16(0), ...u16(0), ...u16(0)]; + const dir: number[] = []; + const body: number[] = []; + for (const t of tables) { + dir.push(...t.tag.split("").map((c) => c.charCodeAt(0))); + dir.push(...u32(0)); // checksum (ignored) + dir.push(...u32(off)); + dir.push(...u32(t.data.length)); + body.push(...t.data); + off += t.data.length; + } + return new Uint8Array([...header, ...dir, ...body]); +} + +function findTable(sfnt: Uint8Array, tag: string): { off: number; len: number } { + const dv = new DataView(sfnt.buffer, sfnt.byteOffset, sfnt.byteLength); + const n = dv.getUint16(4); + for (let i = 0; i < n; i++) { + const o = 12 + i * 16; + const t = String.fromCharCode(sfnt[o], sfnt[o + 1], sfnt[o + 2], sfnt[o + 3]); + if (t === tag) return { off: dv.getUint32(o + 8), len: dv.getUint32(o + 12) }; + } + throw new Error(`no ${tag}`); +} + +describe("cmap language sanitization", () => { + it("zeroes the language field of a non-Mac format-12 subtable", () => { + const out = reconstructTrueType(buildCtfWithBadCmap()); + const dv = new DataView(out.buffer, out.byteOffset, out.byteLength); + const cmap = findTable(out, "cmap"); + const subOff = cmap.off + dv.getUint32(cmap.off + 8); // encoding record offset + expect(dv.getUint16(subOff)).toBe(12); // format 12 preserved + expect(dv.getUint32(subOff + 8)).toBe(0); // language zeroed + // group data still intact ('A' -> glyph 0). + expect(dv.getUint32(subOff + 16)).toBe(65); + }); +}); diff --git a/packages/slidewise/src/lib/fonts/__tests__/ctf-composite.test.ts b/packages/slidewise/src/lib/fonts/__tests__/ctf-composite.test.ts new file mode 100644 index 0000000..a08f1a9 --- /dev/null +++ b/packages/slidewise/src/lib/fonts/__tests__/ctf-composite.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { reconstructTrueType } from "../ctf-glyf"; + +// Build a minimal CTF-format sfnt with one composite glyph whose LAST +// component carries WE_HAVE_INSTRUCTIONS (0x0100). This mirrors the real +// PowerPoint embedded fonts (e.g. "colon" in DM Serif Display) that broke +// the decoder: it only cleared the instructions bit on the FIRST component, +// so a parser would read a non-existent instructionLength past the glyph +// and reject the whole font. The reconstructed glyf must clear the bit on +// EVERY component and leave no trailing instructionLength. + +const WE_HAVE_INSTRUCTIONS = 0x0100; +const ARGS_ARE_XY = 0x0002; +const MORE_COMPONENTS = 0x0020; + +function u16(v: number): number[] { + return [(v >> 8) & 0xff, v & 0xff]; +} + +function buildCtf(): Uint8Array { + // --- CTF glyf for glyph 0: composite, 2 byte-arg components --- + const glyf: number[] = [ + 0xff, 0xff, // numContours = -1 + ...u16(0), ...u16(0), ...u16(100), ...u16(100), // bbox + // component 1: ARGS_ARE_XY | MORE_COMPONENTS, glyphIndex 0, byte args + ...u16(ARGS_ARE_XY | MORE_COMPONENTS), ...u16(0), 0, 0, + // component 2 (last): ARGS_ARE_XY | WE_HAVE_INSTRUCTIONS, glyphIndex 1 + ...u16(ARGS_ARE_XY | WE_HAVE_INSTRUCTIONS), ...u16(1), 0, 0, + // weHaveInstr ⇒ CTF stores pushCount + codeSize (255UShort each) + 0, 0, + ]; + const maxp = new Array(32).fill(0); + maxp[4] = 0; maxp[5] = 1; // numGlyphs = 1 + const head = new Array(54).fill(0); // assembleSfnt patches offsets 8 + 50 + + const tables: Array<{ tag: string; data: number[] }> = [ + { tag: "maxp", data: maxp }, + { tag: "head", data: head }, + { tag: "glyf", data: glyf }, + ]; + const dirSize = 12 + tables.length * 16; + let off = dirSize; + const header: number[] = [ + ...u16(0x0001), ...u16(0x0000), // sfnt version 0x00010000 + ...u16(tables.length), ...u16(0), ...u16(0), ...u16(0), + ]; + const dir: number[] = []; + const body: number[] = []; + for (const t of tables) { + dir.push(...t.tag.split("").map((c) => c.charCodeAt(0))); + dir.push(...u16(0), ...u16(0)); // checksum (ignored) + dir.push(...u16((off >> 16) & 0xffff), ...u16(off & 0xffff)); // offset + dir.push(...u16((t.data.length >> 16) & 0xffff), ...u16(t.data.length & 0xffff)); + body.push(...t.data); + off += t.data.length; + } + return new Uint8Array([...header, ...dir, ...body]); +} + +function tableOffset(sfnt: Uint8Array, tag: string): { off: number; len: number } { + const dv = new DataView(sfnt.buffer, sfnt.byteOffset, sfnt.byteLength); + const n = dv.getUint16(4); + for (let i = 0; i < n; i++) { + const o = 12 + i * 16; + const t = String.fromCharCode(sfnt[o], sfnt[o + 1], sfnt[o + 2], sfnt[o + 3]); + if (t === tag) return { off: dv.getUint32(o + 8), len: dv.getUint32(o + 12) }; + } + throw new Error(`table ${tag} not found`); +} + +describe("CTF composite reconstruction", () => { + it("clears WE_HAVE_INSTRUCTIONS on every component, not just the first", () => { + const out = reconstructTrueType(buildCtf()); + const dv = new DataView(out.buffer, out.byteOffset, out.byteLength); + + const loca = tableOffset(out, "loca"); + const glyf = tableOffset(out, "glyf"); + const g0 = dv.getUint32(loca.off); // long loca + const g1 = dv.getUint32(loca.off + 4); + const base = glyf.off + g0; + const glyphLen = g1 - g0; + + expect(dv.getInt16(base)).toBe(-1); // composite + + // Walk components; assert none keeps WE_HAVE_INSTRUCTIONS and the walk + // ends within the glyph (no dangling instructionLength to read). + let p = base + 10; // skip numContours + bbox + let more = true; + let count = 0; + const end = base + glyphLen; + while (more) { + expect(p + 4).toBeLessThanOrEqual(end); + const flags = dv.getUint16(p); + expect(flags & WE_HAVE_INSTRUCTIONS).toBe(0); + p += 4; // flags + glyphIndex + p += flags & 0x0001 ? 4 : 2; // args + if (flags & 0x0008) p += 2; + else if (flags & 0x0040) p += 4; + else if (flags & 0x0080) p += 8; + more = (flags & MORE_COMPONENTS) !== 0; + count++; + } + expect(count).toBe(2); + // No bytes beyond the components except 0..3 of padding alignment. + expect(end - p).toBeLessThanOrEqual(3); + }); +}); diff --git a/packages/slidewise/src/lib/fonts/ctf-glyf.ts b/packages/slidewise/src/lib/fonts/ctf-glyf.ts index 5404285..36dd10a 100644 --- a/packages/slidewise/src/lib/fonts/ctf-glyf.ts +++ b/packages/slidewise/src/lib/fonts/ctf-glyf.ts @@ -269,9 +269,15 @@ function reconstructComposite(r: Reader, ctf: Uint8Array, glyfRec: TableRec): Ui const bbox: [number, number, number, number] = [r.i16(), r.i16(), r.i16(), r.i16()]; const start = r.pos; let weHaveInstr = false; - // walk components to find their byte span + // Walk components to find their byte span. Record each component's flags + // position (relative to the component block start) so we can clear the + // WE_HAVE_INSTRUCTIONS bit wherever it appears — the spec lets any + // component carry it (it's conventionally on the LAST one), and a stray + // bit makes parsers read a non-existent instructionLength past the glyph. + const flagOffsets: number[] = []; let more = true; while (more) { + flagOffsets.push(r.pos - start); const flags = r.u16(); r.u16(); // glyphIndex if (flags & ARG_1_AND_2_ARE_WORDS) { r.u16(); r.u16(); } else { r.u8(); r.u8(); } @@ -299,12 +305,16 @@ function reconstructComposite(r: Reader, ctf: Uint8Array, glyfRec: TableRec): Ui dv.setInt16(6, bbox[2]); dv.setInt16(8, bbox[3]); out.set(componentBytes, 10); - // Clear WE_HAVE_INSTRUCTIONS on the first component's flags (we dropped them). - if (componentBytes.length >= 2) { - const f = (out[10] << 8) | out[11]; + // Clear WE_HAVE_INSTRUCTIONS on EVERY component's flags — we dropped the + // hint stream and emit no instructionLength, so any surviving bit (most + // often on the last component) would push a parser past the glyph's end. + for (const fo of flagOffsets) { + const o = 10 + fo; + if (o + 1 >= out.length) break; + const f = (out[o] << 8) | out[o + 1]; const cleared = f & ~WE_HAVE_INSTRUCTIONS; - out[10] = (cleared >> 8) & 0xff; - out[11] = cleared & 0xff; + out[o] = (cleared >> 8) & 0xff; + out[o + 1] = cleared & 0xff; } return out; } @@ -370,6 +380,8 @@ function assembleSfnt( const hv = new DataView(data.buffer, data.byteOffset, data.byteLength); hv.setUint32(8, 0); // checkSumAdjustment hv.setInt16(50, 1); // indexToLocFormat + } else if (t.tag === "cmap") { + data = sanitizeCmapLanguages(data); } outTables.push({ tag: t.tag, data }); } @@ -419,6 +431,37 @@ function assembleSfnt( return out; } +/** + * Zero the `language` field of every non-Macintosh cmap subtable. The strict + * browser sanitizer (OTS) rejects the whole font when a format 4/6/12 subtable + * on a Unicode/Windows platform carries a non-zero language (we've seen + * PowerPoint-embedded faces ship `language = 1007` in their format-12 group), + * while fontTools silently tolerates it. `language` is only meaningful for the + * Macintosh platform (platformID 1), so clearing it elsewhere is lossless. + */ +function sanitizeCmapLanguages(src: Uint8Array): Uint8Array { + const data = src.slice(); + if (data.length < 4) return data; + const dv = new DataView(data.buffer, data.byteOffset, data.byteLength); + const numTables = dv.getUint16(2); + for (let i = 0; i < numTables; i++) { + const rec = 4 + i * 8; + if (rec + 8 > data.length) break; + const platformID = dv.getUint16(rec); + if (platformID === 1) continue; // Macintosh: language is meaningful + const subOff = dv.getUint32(rec + 4); + if (subOff + 2 > data.length) continue; + const format = dv.getUint16(subOff); + if (format === 0 || format === 2 || format === 4 || format === 6) { + if (subOff + 6 <= data.length) dv.setUint16(subOff + 4, 0); // 16-bit language + } else if (format === 8 || format === 10 || format === 12 || format === 13) { + if (subOff + 12 <= data.length) dv.setUint32(subOff + 8, 0); // 32-bit language + } + // format 14 (variation selectors) has no language field. + } + return data; +} + function tableChecksum(data: Uint8Array): number { let sum = 0; const n = data.length; diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index a35229c..98c0f56 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -790,9 +790,13 @@ async function walkUnderlay( if (ph) { const isPicPrompt = ph["@_type"] === "pic"; const isOverridden = slidePhKeys.has(placeholderKey(ph)); - // Picture placeholders are "Insert Picture" prompts; when the slide - // supplies an actual image, the prompt panel must hide. - if (isPicPrompt && isOverridden) continue; + // Picture placeholders are "Insert Picture" prompts — an edit-time + // affordance, not slide content. PowerPoint never renders their grey + // prompt panel: when the slide fills the placeholder the in-slide + // renders instead, and when it's left empty nothing renders. + // Either way, suppress the layout/master prompt backing — otherwise an + // unfilled picture placeholder leaks onto the slide as a grey box. + if (isPicPrompt) continue; // When the slide hosts this placeholder, its fill rides on the // slide's text element (TextElement.background) so it stays at the // text's z-index. pptxgenjs can't write those fields back though — @@ -1217,10 +1221,27 @@ async function parseSpOrText( return el; } - // Fill / stroke. Use placeholder-inherited spPr if slide spPr is empty. + // Fill / stroke. An empty placeholder shape — e.g. a picture placeholder + // the slide hosts but hasn't filled with an image — carries no spPr of its + // own and inherits geometry + fill from the layout/master placeholder. + // That's where the template's grey rounded "Insert Picture" prompt lives; + // without inheriting it the placeholder renders as a sharp, transparent + // rect instead of the rounded grey box the template shows. const spPr = sp?.["p:spPr"]; + const phSpPr = layoutPh?.spPr ?? masterPh?.spPr; + // Inherit the placeholder's (often rounded) custGeom silhouette when the + // slide shape declares no geometry of its own. + if (!customPath && phSpPr?.["a:custGeom"]) { + customPath = parseCustGeomPath(phSpPr["a:custGeom"]); + } + // A picture/SVG fill (modern Office icons) wins over solid/gradient — + // it carries the actual art. Resolved to a url("data:…") the renderer + // paints into the shape (clipped to its custGeom path when present). + const blipFill = await extractShapeBlipFill(spPr, ctx); const fillColor = - extractShapeFill(spPr, ctx.theme) + blipFill + ?? extractShapeFill(spPr, ctx.theme) + ?? (phSpPr ? extractShapeFill(phSpPr, ctx.theme) : undefined) ?? resolveStyleFillRef(sp, ctx) ?? "transparent"; const lineProps = spPr?.["a:ln"]; @@ -2840,6 +2861,24 @@ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: n }; } +/** + * Resolve a shape's `` (picture/SVG fill) to a CSS + * `url("data:…")` string. This is the modern Office "icon" pattern — + * a `` whose fill is an image rather than a solid/gradient (often a + * dual PNG+SVG blip). `blipDataUrl` prefers the sharper SVG embed when the + * dual-blip `` extension is present. Returns undefined when + * the shape has no blip fill or the referenced media can't be resolved. + */ +async function extractShapeBlipFill( + spPr: any, + ctx: ParseContext +): Promise { + const blip = spPr?.["a:blipFill"]?.["a:blip"]; + if (!blip) return undefined; + const url = await blipDataUrl(blip, ctx, ctx.slideRels, ctx.slidePath); + return url ? `url("${url}")` : undefined; +} + /** * Extract a CSS background string from a shape's fill spec. Theme-aware. */