Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/pptx-render-and-font-fidelity.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
- **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.
73 changes: 70 additions & 3 deletions packages/slidewise/src/components/editor/ElementView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,21 +681,76 @@ function linearGradientVector(deg: number): {
};
}

/**
* A shape's `fill` may be a picture/SVG fill the importer captured from a
* PPTX `<a:blipFill>` (modern Office icons), stored as `url("data:…")` /
* `url(https://…)`. Pull the bare URL out so it can be painted as an
* `<image>` (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.
const dash = dashStyleFor(el.strokeDash ?? el.dashType, sw);
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 <a:custGeom>) 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 (
<svg
viewBox={`0 0 ${el.path.viewW} ${el.path.viewH}`}
preserveAspectRatio="none"
width="100%"
height="100%"
style={effect}
>
<defs>
<clipPath id={clipId}>
<path d={el.path.d} fillRule={el.path.fillRule ?? "nonzero"} />
</clipPath>
</defs>
<image
href={imageFill}
x={0}
y={0}
width={el.path.viewW}
height={el.path.viewH}
preserveAspectRatio="none"
clipPath={`url(#${clipId})`}
/>
{sw ? (
<path
d={el.path.d}
fill="none"
fillRule={el.path.fillRule ?? "nonzero"}
stroke={stroke}
strokeWidth={sw}
strokeDasharray={dash.dasharray}
vectorEffect="non-scaling-stroke"
/>
) : null}
</svg>
);
}
return (
<svg
viewBox={`0 0 ${el.path.viewW} ${el.path.viewH}`}
Expand All @@ -717,13 +772,25 @@ function ShapeView({ el }: { el: ShapeElement }) {
</svg>
);
}
// PPTX `<a:stretch><a:fillRect/>` 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 (
<div
style={{
width: "100%",
height: "100%",
background: el.fill,
...fillStyle,
borderRadius: el.shape === "rounded" ? (el.radius ?? 16) : 0,
border: sw ? `${sw}px ${dash.borderStyle} ${stroke}` : undefined,
...effectStyle(el.shadow, el.glow, "box"),
Expand All @@ -737,7 +804,7 @@ function ShapeView({ el }: { el: ShapeElement }) {
style={{
width: "100%",
height: "100%",
background: el.fill,
...fillStyle,
borderRadius: "50%",
border: sw ? `${sw}px ${dash.borderStyle} ${stroke}` : undefined,
...effectStyle(el.shadow, el.glow, "box"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { ElementView } from "../ElementView";
import type { ShapeElement } from "@/lib/types";

const base = { rotation: 0, z: 1 };
const DATA_URL =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";

afterEach(cleanup);

describe("ShapeView picture/SVG fills (PPTX <a:blipFill>)", () => {
it("paints a custGeom path image fill via a clipped <image>, 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(<ElementView el={el} />);

// The art renders as an <image> 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(<ElementView el={el} />);
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(<ElementView el={el} />);
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/);
});
});
31 changes: 31 additions & 0 deletions packages/slidewise/src/lib/__tests__/fonts-weight.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
75 changes: 65 additions & 10 deletions packages/slidewise/src/lib/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading