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
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