Skip to content

Commit 80e1b4e

Browse files
fix(pptx): render shape image fills, fix picture placeholders, and load embedded fonts (#73)
* fix(pptx): render shape picture/SVG fills and fix picture-placeholder handling - Resolve <a:blipFill> on shapes to an image fill (prefers the SVG blip) so custGeom icons, globes, stars and grid textures render instead of blank. - ShapeView paints the image clipped to the custGeom path, or as a box-filling background for rect/rounded/circle shapes. - Suppress empty layout picture-placeholder prompt boxes from leaking onto the slide; on-slide picture placeholders inherit geometry + fill from the layout/master placeholder. * fix(fonts): decode embedded EOT/MTX fonts to browser-valid TTF - Clear WE_HAVE_INSTRUCTIONS on every composite component (not just the first) so the reconstructed glyf table parses; a stray bit made OTS read a non-existent instructionLength past the glyph and reject the font. - Sanitize cmap subtable 'language' to 0 on non-Macintosh platforms; some embedded faces ship format-12 language=1007, which OTS rejects. - Alias weight-named embedded families (Montserrat Bold/Semi-Bold) to the base family at the matching numeric weight so base-family bold/semibold text uses the real face instead of a synthetic bold. * chore: add changeset for pptx render + font fidelity fixes
1 parent ff3c7ac commit 80e1b4e

9 files changed

Lines changed: 542 additions & 24 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@textcortex/slidewise": patch
3+
---
4+
5+
Fix several PPTX import-rendering fidelity gaps surfaced by real-world decks:
6+
7+
- **Picture/SVG fills on shapes** — shapes whose fill is an `<a:blipFill>` (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.
8+
- **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.
9+
- **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.
10+
- **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.

packages/slidewise/src/components/editor/ElementView.tsx

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -681,21 +681,76 @@ function linearGradientVector(deg: number): {
681681
};
682682
}
683683

684+
/**
685+
* A shape's `fill` may be a picture/SVG fill the importer captured from a
686+
* PPTX `<a:blipFill>` (modern Office icons), stored as `url("data:…")` /
687+
* `url(https://…)`. Pull the bare URL out so it can be painted as an
688+
* `<image>` (vector shapes) or `background-image` (rect/circle). Gradients
689+
* and solid colours return undefined — they paint via the normal path.
690+
*/
691+
function imageFillUrlOf(fill: string | undefined): string | undefined {
692+
if (!fill) return undefined;
693+
const m = /^\s*url\((['"]?)(.*?)\1\)\s*$/.exec(fill);
694+
return m ? m[2] : undefined;
695+
}
696+
684697
function ShapeView({ el }: { el: ShapeElement }) {
685698
const stroke = el.stroke ?? "transparent";
686699
const sw = el.strokeWidth ?? 0;
700+
const imageFill = imageFillUrlOf(el.fill);
687701
// Accept either `strokeDash` (raw OOXML, set by the importer) or
688702
// `dashType` (typed enum, set by AI-authored / host-supplied decks).
689703
// Raw wins when both are set — it preserves PPTX intent exactly.
690704
const dash = dashStyleFor(el.strokeDash ?? el.dashType, sw);
691705
const effect = effectStyle(el.shadow, el.glow, "filter");
692706
// SVG `fill=` can't take a CSS gradient string, so vector shapes need a
693707
// paint server. Build it once and reuse for path + polygon renderers.
694-
const gradId = `sw-grad-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
708+
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
709+
const gradId = `sw-grad-${uid}`;
695710
const { paint, def } = svgGradientPaint(el.fill, gradId);
696711
// Custom vector path (PPTX <a:custGeom>) takes precedence over the preset
697712
// kind — the path coordinates already encode the actual silhouette.
698713
if (el.path) {
714+
// Picture/SVG fill: paint the image clipped to the silhouette rather
715+
// than handing the renderer an `url(...)` it can't use as an SVG paint.
716+
if (imageFill) {
717+
const clipId = `sw-clip-${uid}`;
718+
return (
719+
<svg
720+
viewBox={`0 0 ${el.path.viewW} ${el.path.viewH}`}
721+
preserveAspectRatio="none"
722+
width="100%"
723+
height="100%"
724+
style={effect}
725+
>
726+
<defs>
727+
<clipPath id={clipId}>
728+
<path d={el.path.d} fillRule={el.path.fillRule ?? "nonzero"} />
729+
</clipPath>
730+
</defs>
731+
<image
732+
href={imageFill}
733+
x={0}
734+
y={0}
735+
width={el.path.viewW}
736+
height={el.path.viewH}
737+
preserveAspectRatio="none"
738+
clipPath={`url(#${clipId})`}
739+
/>
740+
{sw ? (
741+
<path
742+
d={el.path.d}
743+
fill="none"
744+
fillRule={el.path.fillRule ?? "nonzero"}
745+
stroke={stroke}
746+
strokeWidth={sw}
747+
strokeDasharray={dash.dasharray}
748+
vectorEffect="non-scaling-stroke"
749+
/>
750+
) : null}
751+
</svg>
752+
);
753+
}
699754
return (
700755
<svg
701756
viewBox={`0 0 ${el.path.viewW} ${el.path.viewH}`}
@@ -717,13 +772,25 @@ function ShapeView({ el }: { el: ShapeElement }) {
717772
</svg>
718773
);
719774
}
775+
// PPTX `<a:stretch><a:fillRect/>` fills the box edge-to-edge; mirror that
776+
// with a non-repeating, box-sized background image. Use the
777+
// `background-image` longhand (not the `background` shorthand, which would
778+
// reset background-size back to its initial value).
779+
const fillStyle: React.CSSProperties = imageFill
780+
? {
781+
backgroundImage: el.fill,
782+
backgroundSize: "100% 100%",
783+
backgroundRepeat: "no-repeat",
784+
backgroundPosition: "center",
785+
}
786+
: { background: el.fill };
720787
if (el.shape === "rect" || el.shape === "rounded") {
721788
return (
722789
<div
723790
style={{
724791
width: "100%",
725792
height: "100%",
726-
background: el.fill,
793+
...fillStyle,
727794
borderRadius: el.shape === "rounded" ? (el.radius ?? 16) : 0,
728795
border: sw ? `${sw}px ${dash.borderStyle} ${stroke}` : undefined,
729796
...effectStyle(el.shadow, el.glow, "box"),
@@ -737,7 +804,7 @@ function ShapeView({ el }: { el: ShapeElement }) {
737804
style={{
738805
width: "100%",
739806
height: "100%",
740-
background: el.fill,
807+
...fillStyle,
741808
borderRadius: "50%",
742809
border: sw ? `${sw}px ${dash.borderStyle} ${stroke}` : undefined,
743810
...effectStyle(el.shadow, el.glow, "box"),
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, afterEach } from "vitest";
3+
import { render, cleanup } from "@testing-library/react";
4+
import { ElementView } from "../ElementView";
5+
import type { ShapeElement } from "@/lib/types";
6+
7+
const base = { rotation: 0, z: 1 };
8+
const DATA_URL =
9+
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";
10+
11+
afterEach(cleanup);
12+
13+
describe("ShapeView picture/SVG fills (PPTX <a:blipFill>)", () => {
14+
it("paints a custGeom path image fill via a clipped <image>, not an SVG path fill", () => {
15+
const el: ShapeElement = {
16+
...base,
17+
id: "icon",
18+
type: "shape",
19+
x: 0,
20+
y: 0,
21+
w: 200,
22+
h: 200,
23+
shape: "rect",
24+
fill: `url("${DATA_URL}")`,
25+
path: { d: "M 0 0 L 100 0 L 100 100 Z", viewW: 100, viewH: 100 },
26+
};
27+
const { container } = render(<ElementView el={el} />);
28+
29+
// The art renders as an <image> clipped to the silhouette.
30+
const image = container.querySelector("image");
31+
expect(image).toBeTruthy();
32+
expect(image?.getAttribute("href")).toBe(DATA_URL);
33+
expect(image?.getAttribute("clip-path") ?? "").toMatch(/^url\(#/);
34+
35+
const clip = container.querySelector("clipPath path");
36+
expect(clip?.getAttribute("d")).toBe("M 0 0 L 100 0 L 100 100 Z");
37+
38+
// The url() must never be handed to an SVG path fill (invalid → blank).
39+
const fillPath = container.querySelector("path[fill^='url(\"']");
40+
expect(fillPath).toBeNull();
41+
});
42+
43+
it("draws the silhouette stroke on top of the image when the shape is stroked", () => {
44+
const el: ShapeElement = {
45+
...base,
46+
id: "icon-stroked",
47+
type: "shape",
48+
x: 0,
49+
y: 0,
50+
w: 200,
51+
h: 200,
52+
shape: "rect",
53+
fill: `url("${DATA_URL}")`,
54+
stroke: "#FF0000",
55+
strokeWidth: 3,
56+
path: { d: "M 0 0 L 100 0 L 100 100 Z", viewW: 100, viewH: 100 },
57+
};
58+
const { container } = render(<ElementView el={el} />);
59+
const strokePath = container.querySelector("path[stroke='#FF0000']");
60+
expect(strokePath).toBeTruthy();
61+
expect(strokePath?.getAttribute("fill")).toBe("none");
62+
});
63+
64+
it("fills a rect shape edge-to-edge via a non-repeating background image", () => {
65+
const el: ShapeElement = {
66+
...base,
67+
id: "panel",
68+
type: "shape",
69+
x: 0,
70+
y: 0,
71+
w: 300,
72+
h: 100,
73+
shape: "rect",
74+
fill: `url("${DATA_URL}")`,
75+
};
76+
const { container } = render(<ElementView el={el} />);
77+
const div = container.querySelector("div");
78+
const style = div?.getAttribute("style") ?? "";
79+
expect(style).toContain(DATA_URL);
80+
expect(style).toMatch(/background-size:\s*100%\s+100%/);
81+
expect(style).toMatch(/background-repeat:\s*no-repeat/);
82+
});
83+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, it, expect } from "vitest";
2+
import { splitFamilyWeight } from "../fonts";
3+
4+
describe("splitFamilyWeight", () => {
5+
it("maps weight-named families to base + numeric weight", () => {
6+
expect(splitFamilyWeight("Montserrat Bold")).toEqual({ base: "Montserrat", weight: 700 });
7+
expect(splitFamilyWeight("Montserrat Semi-Bold")).toEqual({ base: "Montserrat", weight: 600 });
8+
expect(splitFamilyWeight("Montserrat SemiBold")).toEqual({ base: "Montserrat", weight: 600 });
9+
expect(splitFamilyWeight("Open Sans Light")).toEqual({ base: "Open Sans", weight: 300 });
10+
expect(splitFamilyWeight("Roboto Medium")).toEqual({ base: "Roboto", weight: 500 });
11+
expect(splitFamilyWeight("Inter Extra Bold")).toEqual({ base: "Inter", weight: 800 });
12+
expect(splitFamilyWeight("Inter Black")).toEqual({ base: "Inter", weight: 900 });
13+
expect(splitFamilyWeight("Lato Thin")).toEqual({ base: "Lato", weight: 100 });
14+
});
15+
16+
it("prefers the most specific suffix (Semi/Extra Bold over Bold)", () => {
17+
// A bare "...Bold" rule must not strip "Semi-Bold" down to "...Semi".
18+
expect(splitFamilyWeight("Helvetica Neue Semibold")).toEqual({
19+
base: "Helvetica Neue",
20+
weight: 600,
21+
});
22+
});
23+
24+
it("returns null when there is no weight suffix", () => {
25+
expect(splitFamilyWeight("DM Serif Display")).toBeNull();
26+
expect(splitFamilyWeight("Montserrat")).toBeNull();
27+
expect(splitFamilyWeight("Arial")).toBeNull();
28+
// Must not strip a weight word that is the whole name / leaves nothing.
29+
expect(splitFamilyWeight("Bold")).toBeNull();
30+
});
31+
});

packages/slidewise/src/lib/fonts.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ function escapeCss(s: string): string {
210210
* 1. `Deck.webFonts` — per-deck overrides, AI-authored decks ship these.
211211
* 2. `fontRegistry` — host-wide brand fonts the platform owns.
212212
* 3. **Decoded `Deck.fonts`** — embedded `.fntdata` payloads the importer
213-
* pulled from `ppt/fonts/`. When the EOT is uncompressed (or uses an
214-
* MTX sub-method we can decode), we synthesise a `data:font/ttf;…`
215-
* URL on the fly. Brand-embedded fonts that use MTX glyph compression
216-
* can't be decoded yet and are skipped — `fontRegistry` is the
217-
* documented fallback for those cases.
213+
* pulled from `ppt/fonts/`. These are EOT, usually MicroType-Express
214+
* (MTX) compressed; `decodeEot` decompresses them and reconstructs the
215+
* `glyf` table into a browser-valid TTF, surfaced as a `data:font/ttf;…`
216+
* URL. A font that still can't be decoded (truncated / unsupported
217+
* variant) is skipped and `fontRegistry` is the documented fallback.
218218
*
219219
* The first source to claim a `(family, weight, italic)` tuple wins.
220220
*/
@@ -267,10 +267,11 @@ export function resolveWebFonts(
267267

268268
/**
269269
* Convert a `Deck.fonts` entry (raw `.fntdata` from `ppt/fonts/`) into a
270-
* `WebFontAsset` the editor can render. Returns `null` when the EOT is
271-
* MTX-compressed (we have a partial decoder; the brand-font glyph encoder
272-
* isn't done yet — see `./fonts/mtx.ts`) so callers can move on to the
273-
* registry / system-font fallback chain.
270+
* `WebFontAsset` the editor can render. `decodeEot` handles uncompressed and
271+
* MicroType-Express (MTX) compressed EOT, reconstructing the `glyf` table.
272+
* Returns `null` only when the payload still can't be decoded (truncated /
273+
* unsupported variant) so callers can fall back to the registry / system
274+
* font chain.
274275
*
275276
* The returned asset uses a `data:font/ttf;base64,...` URL so the resulting
276277
* `@font-face` is fully self-contained — no CDN, no network request.
@@ -330,17 +331,71 @@ export function fontAssetToWebFont(asset: FontAsset): WebFontAsset | null {
330331
}
331332
}
332333

334+
/**
335+
* Weight-name suffixes a font family can carry, longest/most-specific first
336+
* so "Semi Bold" / "Extra Bold" win over a bare "Bold" match. Used to alias a
337+
* weight-named embedded family (e.g. "Montserrat Bold") to its base family at
338+
* the matching numeric weight, so text that asks for the base family in bold
339+
* ("Montserrat" + b) renders with the REAL bold face the deck shipped instead
340+
* of a synthetic (faux) bold of the regular face.
341+
*/
342+
const WEIGHT_SUFFIXES: Array<[RegExp, number]> = [
343+
[/[\s-]?thin$/i, 100],
344+
[/[\s-]?(?:extra|ultra)[\s-]?light$/i, 200],
345+
[/[\s-]?light$/i, 300],
346+
[/[\s-]?regular$/i, 400],
347+
[/[\s-]?normal$/i, 400],
348+
[/[\s-]?medium$/i, 500],
349+
[/[\s-]?(?:semi|demi)[\s-]?bold$/i, 600],
350+
[/[\s-]?(?:extra|ultra)[\s-]?bold$/i, 800],
351+
[/[\s-]?(?:black|heavy)$/i, 900],
352+
[/[\s-]?bold$/i, 700],
353+
];
354+
355+
/**
356+
* Split a trailing weight word off a family name. "Montserrat Semi-Bold" →
357+
* { base: "Montserrat", weight: 600 }. Returns null when the family carries no
358+
* recognised weight suffix (e.g. "DM Serif Display").
359+
*/
360+
export function splitFamilyWeight(
361+
family: string
362+
): { base: string; weight: number } | null {
363+
for (const [re, weight] of WEIGHT_SUFFIXES) {
364+
if (re.test(family)) {
365+
const base = family.replace(re, "").trim();
366+
if (base.length) return { base, weight };
367+
}
368+
}
369+
return null;
370+
}
371+
333372
/**
334373
* Bulk-convert `Deck.fonts` → `WebFontAsset[]` filtering out the entries
335374
* we couldn't decode. Safe to call eagerly inside a `useMemo` because
336375
* decoding a 200KB font runs in single-digit ms.
376+
*
377+
* For each weight-named family we ALSO emit an alias under the base family at
378+
* the matching numeric weight (Montserrat Bold → Montserrat / 700), so a run
379+
* that asks for "Montserrat" in bold binds to the real bold face rather than
380+
* synthesising one. The original family is kept too, so runs that name the
381+
* weight-variant directly still resolve.
337382
*/
338383
export function decodeDeckEmbeddedFonts(deck: Deck): WebFontAsset[] {
339384
if (!deck.fonts || !deck.fonts.length) return [];
340385
const out: WebFontAsset[] = [];
341386
for (const asset of deck.fonts) {
342387
const web = fontAssetToWebFont(asset);
343-
if (web) out.push(web);
388+
if (!web) continue;
389+
out.push(web);
390+
const split = splitFamilyWeight(web.family);
391+
if (split) {
392+
out.push({
393+
family: split.base,
394+
src: web.src,
395+
weight: split.weight,
396+
italic: web.italic,
397+
});
398+
}
344399
}
345400
return out;
346401
}

0 commit comments

Comments
 (0)