Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions demos/nextjs-ssr/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
public/fonts/
21 changes: 21 additions & 0 deletions demos/nextjs-ssr/copy-fonts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copy SuperDoc's bundled metric-compatible font substitutes into public/fonts/ so they
// are served at /fonts/ (the default asset base). Without this, the bundled .woff2 404
// and SuperDoc paginates against a browser fallback. Runs as `predev`/`prebuild`.
//
// A real Next.js consumer would copy from `node_modules/@superdoc/font-system/assets/`
// (or set `fonts.assetBaseUrl` to wherever they serve them); this example copies from the
// workspace build for a self-contained demo.
import { cpSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, '../../packages/superdoc/dist/fonts');
const dst = resolve(here, 'public/fonts');

if (existsSync(src)) {
cpSync(src, dst, { recursive: true });
console.log('[nextjs-ssr] copied bundled fonts -> public/fonts/');
} else {
console.warn(`[nextjs-ssr] bundled fonts not found at ${src}; run \`pnpm build:superdoc\` first`);
}
2 changes: 2 additions & 0 deletions demos/nextjs-ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"predev": "node copy-fonts.mjs",
"dev": "next dev --turbopack",
"prebuild": "node copy-fonts.mjs",
"build": "next build",
"start": "next start",
"lint": "next lint"
Expand Down
14 changes: 12 additions & 2 deletions examples/getting-started/cdn/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// so `index.html` is self-contained and can be served with `npx serve .`.
// Run before `dev` or the Playwright smoke test.

import { copyFileSync, existsSync } from 'node:fs';
import { copyFileSync, cpSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -19,16 +19,26 @@ const assets = [
[sampleSource, resolve(here, 'test_file.docx')],
];

// The bundled metric-compatible substitutes ship as separate assets under dist/fonts/.
// The CDN build auto-detects `./fonts/` relative to superdoc.min.js, so the example must
// serve them beside the script (here/fonts/) or every .woff2 404s.
const fontsSrc = resolve(dist, 'fonts');
const fontsDst = resolve(here, 'fonts');

const missing = assets.filter(([src]) => !existsSync(src));
if (missing.length) {
if (missing.length || !existsSync(fontsSrc)) {
console.error('[cdn-example/setup] Build the SuperDoc bundle first:');
console.error(' pnpm --filter superdoc build');
console.error('Missing files:');
for (const [src] of missing) console.error(' ' + src);
if (!existsSync(fontsSrc)) console.error(' ' + fontsSrc + ' (bundled font assets)');
process.exit(1);
}

for (const [src, dst] of assets) {
copyFileSync(src, dst);
console.log('[cdn-example/setup] copied', dst.replace(here + '/', ''));
}

cpSync(fontsSrc, fontsDst, { recursive: true });
console.log('[cdn-example/setup] copied fonts/ (bundled substitute pack)');
1 change: 1 addition & 0 deletions examples/getting-started/laravel/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ composer.lock
/.nova
/.vscode
/.zed
/public/fonts/
20 changes: 20 additions & 0 deletions examples/getting-started/laravel/copy-fonts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copy SuperDoc's bundled metric-compatible font substitutes into public/fonts/ so Laravel
// serves them at /fonts/ (the default asset base). Without this, the bundled .woff2 404 and
// SuperDoc paginates against a browser fallback. Runs before `vite build` in `start`.
//
// A real Laravel app would copy from `node_modules/@superdoc/font-system/assets/` (or set
// `fonts.assetBaseUrl`); this example copies from the workspace build for a self-contained demo.
import { cpSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, '../../../packages/superdoc/dist/fonts');
const dst = resolve(here, 'public/fonts');

if (existsSync(src)) {
cpSync(src, dst, { recursive: true });
console.log('[laravel] copied bundled fonts -> public/fonts/');
} else {
console.warn(`[laravel] bundled fonts not found at ${src}; run \`pnpm build:superdoc\` first`);
}
6 changes: 3 additions & 3 deletions examples/getting-started/laravel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "concurrently \"php artisan serve --host=0.0.0.0 --port=8000\" \"vite\"",
"start": "vite build && php artisan serve --host=127.0.0.1 --port=8000"
"build": "node copy-fonts.mjs && vite build",
"dev": "node copy-fonts.mjs && concurrently \"php artisan serve --host=0.0.0.0 --port=8000\" \"vite\"",
"start": "node copy-fonts.mjs && vite build && php artisan serve --host=127.0.0.1 --port=8000"
},
"dependencies": {
"concurrently": "^9.0.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/layout-engine/layout-resolved/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
},
"dependencies": {
"@superdoc/common": "workspace:*",
"@superdoc/contracts": "workspace:*"
"@superdoc/contracts": "workspace:*",
"@superdoc/font-system": "workspace:*"
},
"devDependencies": {
"tsup": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type TextRun,
type VectorShapeDrawing,
} from '@superdoc/contracts';
import { getFontConfigVersion } from '@superdoc/font-system';
import { hashParagraphBorders } from './paragraphBorderHash.js';
import {
hashCellBorders,
Expand Down Expand Up @@ -343,6 +344,9 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
return [
textRun.text ?? '',
textRun.fontFamily,
// Font epoch: busts paint reuse when a font loads/changes (the resolved physical
// family is the same, only its availability changed - logical family alone can't see it).
getFontConfigVersion(),
textRun.fontSize,
textRun.bold ? 1 : 0,
textRun.italic ? 1 : 0,
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/measuring/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@superdoc/contracts": "workspace:*",
"@superdoc/common": "workspace:*",
"@superdoc/font-system": "workspace:*",
"@superdoc/font-utils": "workspace:*",
"@superdoc/geometry-utils": "workspace:*",
"@superdoc/word-layout": "workspace:*"
Expand Down
34 changes: 32 additions & 2 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
import { resolveListTextStartPx, type MinimalMarker } from '@superdoc/common/list-marker-utils';
import { calculateRotatedBounds, normalizeRotation } from '@superdoc/geometry-utils';
import { toCssFontFamily } from '@superdoc/font-utils';
import { resolvePhysicalFamily } from '@superdoc/font-system';
export { installNodeCanvasPolyfill } from './setup.js';
import { clearMeasurementCache, getMeasuredTextWidth, setCacheSize } from './measurementCache.js';
import { getFontMetrics, clearFontMetricsCache, type FontInfo } from './fontMetricsCache.js';
Expand All @@ -88,13 +89,35 @@ import type { FixedLayoutResult } from './fixed-table-columns.js';
import {
buildAutoFitTableResultCacheKey,
buildTableCellContentMetricsCacheKey,
clearTableAutoFitMeasurementCaches,
getCachedAutoFitTableResult,
type TableAutoFitContentMetricsResult,
measureTableAutoFitContentMetrics,
setCachedAutoFitTableResult,
} from './table-autofit-metrics.js';

export { clearFontMetricsCache };
export { clearTableAutoFitMeasurementCaches };

/**
* Clear every font-dependent text-measurement cache owned by `measuring/dom`:
* text advance widths, font ascent/descent metrics, and AutoFit cell metrics.
*
* Call this when the set of available fonts changes (a face finishes loading,
* or a substitution/mapping is added) so the next measurement pass re-measures
* with the correct font instead of reusing results taken against a fallback.
* The caller is also responsible for clearing the layout-bridge block-measure
* cache (`measureCache.clear()`), which holds derived block measures.
*/
export function clearTextMeasurementCaches(): void {
clearMeasurementCache();
clearFontMetricsCache();
clearTableAutoFitMeasurementCaches();
// Drop the persistent measuring canvas. A 2D context caches its font resolution: once it
// measured a family while the font was absent (falling back), it keeps using the fallback
// even after the font loads. A fresh context re-resolves to the now-available font.
canvasContext = null;
}

const { computeTabStops } = Engines;

Expand Down Expand Up @@ -301,19 +324,26 @@ function buildFontString(run: { fontFamily: string; fontSize: number; bold?: boo
if (run.bold) parts.push('bold');
parts.push(`${run.fontSize}px`);

// Resolve the logical family (e.g. "Calibri") to the physical render family
// (e.g. "Carlito") so text is MEASURED in the same font it is painted with. The
// measure cache keys on this font string, so the physical family is in the key.
const physicalFamily = resolvePhysicalFamily(run.fontFamily);

if (measurementConfig.mode === 'deterministic') {
// Deterministic mode still flattens to one family for reproducible server-side
// measurement; per-font resolution here is follow-up T1 work (browser mode first).
parts.push(
measurementConfig.fonts.fallbackStack.length > 0
? measurementConfig.fonts.fallbackStack.join(', ')
: measurementConfig.fonts.deterministicFamily,
);
} else {
parts.push(run.fontFamily);
parts.push(physicalFamily);
}

return {
font: parts.join(' '),
fontFamily: run.fontFamily,
fontFamily: physicalFamily,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@superdoc/common": "workspace:*",
"@superdoc/contracts": "workspace:*",
"@superdoc/dom-contract": "workspace:*",
"@superdoc/font-system": "workspace:*",
"@superdoc/font-utils": "workspace:*",
"@superdoc/preset-geometry": "workspace:*",
"@superdoc/url-validation": "workspace:*"
Expand Down
6 changes: 5 additions & 1 deletion packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3200,7 +3200,11 @@ describe('DomPainter', () => {
expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text');
expect(placeholder?.dataset.pmStart).toBe('4');
expect(placeholder?.dataset.pmEnd).toBe('4');
expect(placeholder?.style.fontFamily).toBe('Arial');
// Painted with the resolved PHYSICAL family (Arial -> Liberation Sans), like all
// painted text - the placeholder chrome goes through the same paint path. The logical
// family is preserved for export, not in painted DOM. Quoted because the serialized
// CSS value wraps a multi-word family name.
expect(placeholder?.style.fontFamily).toBe('"Liberation Sans"');
expect(placeholder?.style.fontSize).toBe('16px');
expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px');
expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('220px');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ImageRun, ParagraphAttrs, ParagraphBlock, TextRun, TrackedChangeMeta } from '@superdoc/contracts';
import { getParagraphInlineDirection } from '@superdoc/contracts';
import { getFontConfigVersion } from '@superdoc/font-system';
import { hashParagraphBorders } from '../paragraph-hash-utils.js';
import {
getRunBooleanProp,
Expand Down Expand Up @@ -150,6 +151,9 @@ export const deriveParagraphBlockVersion = (
return [
textRun.text ?? '',
textRun.fontFamily,
// Font epoch: busts block paint reuse when a font loads/changes (logical family
// alone cannot see a substitute becoming available after first paint).
getFontConfigVersion(),
textRun.fontSize,
textRun.bold ? 1 : 0,
textRun.italic ? 1 : 0,
Expand Down
5 changes: 4 additions & 1 deletion packages/layout-engine/painters/dom/src/runs/text-run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FlowRunLink, Run, TextRun } from '@superdoc/contracts';
import { normalizeBaselineShift, resolveBaseFontSizeForVerticalText } from '@superdoc/contracts';
import { resolvePhysicalFamily } from '@superdoc/font-system';
import { assertPmPositions } from '../pm-position-validation.js';
import type { FragmentRenderContext } from '../renderer.js';
import { BROWSER_DEFAULT_FONT_SIZE } from '../styles.js';
Expand Down Expand Up @@ -72,7 +73,9 @@ export const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false):
return;
}

element.style.fontFamily = run.fontFamily;
// Paint the physical render family (e.g. Carlito for Calibri) - the same family the
// text was measured in, so glyph advances match the laid-out positions.
element.style.fontFamily = resolvePhysicalFamily(run.fontFamily);
element.style.fontSize = `${run.fontSize}px`;
if (run.bold) element.style.fontWeight = 'bold';
if (run.italic) element.style.fontStyle = 'italic';
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"@superdoc/contracts": "workspace:*",
"@superdoc/dom-contract": "workspace:*",
"@superdoc/document-api": "workspace:*",
"@superdoc/font-system": "workspace:*",
"@superdoc/font-utils": "workspace:*",
"@superdoc/layout-bridge": "workspace:*",
"@superdoc/layout-resolved": "workspace:*",
Expand Down
6 changes: 6 additions & 0 deletions packages/super-editor/src/editors/v1/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,9 @@ export class Editor extends EventEmitter<EditorEventMap> {
this.on('comment-positions', this.options.onCommentLocationsUpdate!);
this.on('list-definitions-change', this.options.onListDefinitionsChange!);
this.on('fonts-resolved', this.options.onFontsResolved!);
// Emitted unconditionally by PresentationEditor, so only register a real callback -
// a bare `this.on('fonts-changed', undefined)` would make `emit` call undefined.
if (this.options.onFontsChanged) this.on('fonts-changed', this.options.onFontsChanged);
this.on('exception', this.options.onException!);
this.on('pointerDown', this.options.onPointerDown!);
this.#trackContentControlPointer();
Expand Down Expand Up @@ -1527,6 +1530,9 @@ export class Editor extends EventEmitter<EditorEventMap> {
this.on('comment-positions', this.options.onCommentLocationsUpdate!);
this.on('list-definitions-change', this.options.onListDefinitionsChange!);
this.on('fonts-resolved', this.options.onFontsResolved!);
// Emitted unconditionally by PresentationEditor, so only register a real callback -
// a bare `this.on('fonts-changed', undefined)` would make `emit` call undefined.
if (this.options.onFontsChanged) this.on('fonts-changed', this.options.onFontsChanged);
this.on('exception', this.options.onException!);
this.on('pointerDown', this.options.onPointerDown!);
this.#trackContentControlPointer();
Expand Down
Loading
Loading