diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md new file mode 100644 index 0000000000..c4e0a5b34f --- /dev/null +++ b/.changeset/justified-lines-footnote-fit.md @@ -0,0 +1,6 @@ +--- +'@docx-editor.dev/core': minor +'@docx-editor.dev/docx-to-pdf': patch +--- + +Long documents with justified text, footnotes, tables, and SimSun text now break lines and pages more accurately. PDF export now completes long documents and uses installed Garamond and large CJK fonts. diff --git a/docs/api/docx-editor-core/layout.api.md b/docs/api/docx-editor-core/layout.api.md index 31c18772c0..8193bad086 100644 --- a/docs/api/docx-editor-core/layout.api.md +++ b/docs/api/docx-editor-core/layout.api.md @@ -1061,6 +1061,7 @@ export function exportSourceRangeOf(span: StyleSpanRecord): SourceRange | null; // @public export interface FieldAtomMarker { + readonly fixedResult?: true; readonly formControl?: { readonly accessibleName?: string; readonly checked: boolean; @@ -3931,6 +3932,7 @@ export interface SemanticTableRow { // @public export interface SemanticTableStructure { readonly alignment: TableAlignment; + readonly autoOuterRulePt?: number; readonly bidiVisual?: true; readonly cellSpacingPt: number; // (undocumented) diff --git a/docs/api/docx-editor-docx-to-pdf/index.api.md b/docs/api/docx-editor-docx-to-pdf/index.api.md index cd1586dd56..42680263d7 100644 --- a/docs/api/docx-editor-docx-to-pdf/index.api.md +++ b/docs/api/docx-editor-docx-to-pdf/index.api.md @@ -165,12 +165,13 @@ export class PdfEncodingError extends Error { } // @public -export interface PdfExportOptions extends Omit { +export interface PdfExportOptions extends Omit { readonly comments?: boolean; readonly displayMode?: ReviewDisplayMode; readonly documentLigatures?: boolean; readonly fallbackFonts?: PdfFontsSource; readonly fidelityPolicy?: 'strict' | 'best-effort'; + readonly fontShaping?: FontShapingOptions; readonly fonts?: PdfFontsSource; readonly lastResortFonts?: PdfFontsSource; readonly maxOutputBytes?: number; diff --git a/docs/site/content/export/pdf/api.mdx b/docs/site/content/export/pdf/api.mdx index 23d2be8f5a..95033840ac 100644 --- a/docs/site/content/export/pdf/api.mdx +++ b/docs/site/content/export/pdf/api.mdx @@ -158,7 +158,7 @@ Choose best-effort output explicitly after reviewing your application's fidelity ## Resource boundaries -The writer limits operations to `2000000`, diagnostics to `10000`, and uncompressed content to `67108864` bytes. Core also bounds DOCX parsing, fonts, images, and layout. +The writer limits diagnostics to `10000`. It limits paint operations to `2000000` or `6000` for each laid-out page, whichever is greater, up to `40000000`. It limits uncompressed content to `67108864` bytes or `196608` bytes for each page, whichever is greater, up to `536870912` bytes. Text shaping accepts font faces up to `41943040` bytes (40 MiB) unless you set `fontShaping`, so large installed CJK font collections are used rather than replaced. Core also bounds DOCX parsing, fonts, images, and layout. `maxPages` and `maxOutputBytes` do not impose a process memory limit. They check completed layout and completed encoding, respectively. diff --git a/packages/core/src/layout/__tests__/footnote-holdout-frontier.test.ts b/packages/core/src/layout/__tests__/footnote-holdout-frontier.test.ts index 534eeb6185..81840b621a 100644 --- a/packages/core/src/layout/__tests__/footnote-holdout-frontier.test.ts +++ b/packages/core/src/layout/__tests__/footnote-holdout-frontier.test.ts @@ -120,6 +120,8 @@ describe('footnote hold-out uses the returning paragraph opening', () => { 14 ); // The joined reference ends at 644pt. The separator and minimum body band take 28pt. + // The reference line stays out; the five lines ahead of it return, and widow control + // keeps the last line with it. for (const height of [648, 672]) { const bodyPage = { ...input.bodyPage, contentBox: { ...input.bodyPage.contentBox, height } }; const nextPage = { @@ -128,7 +130,7 @@ describe('footnote hold-out uses the returning paragraph opening', () => { }; for (const usedReservePt of [undefined, height - 560 - 0.5]) { const hold = holdOutReserveNeed({ ...input, bodyPage, nextPage, usedReservePt }); - expect(hold).toBeCloseTo(height - 560 - 0.5, 6); + expect(hold).toBeCloseTo(height - 560 - 5 * 14 - 0.5, 6); } } // A positive budget can require splitting a taller note; retain that existing release. diff --git a/packages/core/src/layout/__tests__/footnote-holdout-leading-lines.test.ts b/packages/core/src/layout/__tests__/footnote-holdout-leading-lines.test.ts new file mode 100644 index 0000000000..f020f73a21 --- /dev/null +++ b/packages/core/src/layout/__tests__/footnote-holdout-leading-lines.test.ts @@ -0,0 +1,160 @@ +// The footnote hold-out keeps a reference line off the previous page, not the lines ahead +// of it. A paragraph that continues onto the next page and cites a note on a later line +// returns its earlier lines, as far as widow and orphan control allow. + +import { describe, expect, test } from 'bun:test'; +import { readOoxmlPart } from '../../store/package/ooxml-tree.ts'; +import { createFixedMeasurer } from '../fixed-measurer.ts'; +import { holdOutReserveNeed, type HoldOutArgs } from '../note-reserve-holdout.ts'; +import type { PageRecord, ParagraphFragmentRecord } from '../semantic-records.ts'; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const LINE = 14; +const CONTENT = 648; + +function notes(lines: number) { + const runs = Array.from( + { length: lines }, + (_, line) => `${line ? '' : ''}N${line + 1}` + ).join(''); + const result = readOoxmlPart( + `${runs}`, + { name: '/word/footnotes.xml', contentType: 'application/xml' } + ); + if (!result.ok) throw new Error(result.reason); + return result.part; +} + +function paragraph( + id: string, + top: number, + count: number, + options: { start?: number; continuation?: boolean; end?: boolean; props?: string[] } = {} +): ParagraphFragmentRecord { + const start = options.start ?? 0; + return { + kind: 'paragraph', + id: `${id}#${start}`, + paragraphId: id, + fragmentIndex: options.continuation ? 1 : 0, + paragraphEnd: options.end ?? true, + range: { start, end: start + count * 10 }, + box: { x: 0, y: top, width: 468, height: count * LINE }, + props: (options.props ?? []).map((localName) => ({ + kind: 'generic', + localName, + attributes: {}, + children: [], + })), + spacing: { before: 0, after: 0, line: null, lineRule: 'auto' }, + indent: { start: 0, end: 0, firstLine: 0, hanging: 0 }, + lines: Array.from({ length: count }, (_, index) => ({ + id: `${id}-${start}-${index}`, + range: { paragraphId: id, start: start + index * 10, end: start + (index + 1) * 10 }, + spans: [], + box: { x: 0, y: top + index * LINE, width: 468, height: LINE }, + contentX: 0, + baseline: 11, + leading: 0, + })), + } as ParagraphFragmentRecord; +} + +function page(index: number, fragments: readonly ParagraphFragmentRecord[]): PageRecord { + return { + id: `page-${index}`, + index, + box: { x: 0, y: 0, width: 612, height: 792 }, + contentBox: { x: 72, y: 72, width: 468, height: CONTENT }, + fragments, + }; +} + +/** + * A page of 40 filler lines, then `opening` lines of the cited paragraph; the next page + * holds the paragraph's other `rest` lines, the last of which cites a one-line note. + */ +function input(opening: number, rest: number, props: string[] = [], noteLines = 1): HoldOutArgs { + const filler = paragraph('filler', 0, 40); + const fragments = + opening > 0 ? [filler, paragraph('cited', 560, opening, { end: false, props })] : [filler]; + const start = opening * 10; + const next = paragraph('cited', 0, rest, { start, continuation: opening > 0, props }); + const bodyBottom = 560 + opening * LINE; + return { + bodyPage: page(0, fragments), + nextPage: page(1, [next]), + existingAreaHeight: 0, + ownReservePt: 0, + usedReservePt: CONTENT - bodyBottom - 0.5, + pageBottomRefsOf: (candidate) => + candidate.index === 1 + ? [{ noteId: 1, paragraphId: 'cited', atomOffset: start + (rest - 1) * 10 + 5 }] + : [], + footnotesPart: notes(noteLines), + opts: { measurer: createFixedMeasurer(6, 14), producer: 'holdout-leading-lines' }, + plainSeparatorHeight: LINE, + noteLayoutCache: new Map(), + }; +} + +/** Body lines that may return beside a hold: whole lines of the page's free space. */ +function returningLines(args: HoldOutArgs): number { + const bodyBottom = args.bodyPage.fragments.reduce( + (bottom, fragment) => Math.max(bottom, fragment.box.y + fragment.box.height), + 0 + ); + return Math.floor((CONTENT - bodyBottom - holdOutReserveNeed(args)) / LINE); +} + +describe('footnote hold-out keeps only the reference line out', () => { + test('lines ahead of the reference return, and widow control keeps two beside it', () => { + // Free space for four lines. The reference line and its note cannot both fit, so the + // two lines ahead of the last pair return without it. + expect(returningLines(input(2, 4))).toBe(2); + }); + + test('a reference on the second of two remaining lines returns nothing', () => { + // The pair and a one-line note fit back, so nothing is held. + expect(returningLines(input(2, 2))).toBe(4); + // A two-line note does not fit; widow control keeps the first line with the reference. + expect(returningLines(input(2, 2, [], 2))).toBe(0); + }); + + test('without widow control every line ahead of the reference returns', () => { + const off = input(2, 4); + const next = off.nextPage!.fragments[0] as ParagraphFragmentRecord; + const free = { + ...next, + props: [ + { + kind: 'generic', + localName: 'widowControl', + attributes: { val: '0' }, + children: [], + }, + ], + } as ParagraphFragmentRecord; + expect(returningLines({ ...off, nextPage: page(1, [free]) })).toBe(3); + }); + + test('a paragraph kept together returns no line', () => { + expect(returningLines(input(2, 4, ['keepLines']))).toBe(0); + }); + + test('a paragraph that starts on the next page returns at least two lines', () => { + expect(returningLines(input(0, 4))).toBe(2); + }); + + test('a paragraph citing a second note on the next page keeps the full hold', () => { + const args = input(2, 4); + const second = { noteId: 1, paragraphId: 'cited', atomOffset: 20 + 2 * 10 + 5 }; + const refs = args.pageBottomRefsOf; + expect( + returningLines({ + ...args, + pageBottomRefsOf: (candidate) => [...refs(candidate), ...(candidate.index ? [second] : [])], + }) + ).toBe(0); + }); +}); diff --git a/packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts b/packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts new file mode 100644 index 0000000000..f09ccd2aa9 --- /dev/null +++ b/packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'bun:test'; +import { zipSync, strToU8 } from 'fflate'; +import type { PageRecord } from '../semantic-records.ts'; +import { readOoxmlPackage } from '../../store/package/ooxml-package.ts'; +import { resolveNotesPart } from '../../store/package/note-references.ts'; +import { + resolveEndnoteProperties, + resolveFootnoteProperties, +} from '../../store/package/note-properties.ts'; +import { createFixedMeasurer } from '../fixed-measurer.ts'; +import { createLayoutSession } from '../layout-session.ts'; +import { layoutSemanticDocument } from '../semantic-layout.ts'; +import { applyTreeOp } from '../../store/store/tree-ops.ts'; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +const CT = 'http://schemas.openxmlformats.org/package/2006/content-types'; +const REL = 'http://schemas.openxmlformats.org/package/2006/relationships'; +/** Page height; the note area is a 12pt separator plus one 12pt note line. */ +const PAGE = 100; +const AREA_TOP = PAGE - 24; +const LINE = 12; + +const run = (text: string) => `${text}`; +const p = (text: string, after = 0, height = LINE) => + `${run(text)}`; + +function load(body: string) { + const entries = { + '[Content_Types].xml': ``, + '_rels/.rels': ``, + 'word/_rels/document.xml.rels': ``, + 'word/document.xml': `${body}`, + 'word/footnotes.xml': `${p('')}${p('')}${p('Note')}`, + }; + const bytes = zipSync( + Object.fromEntries(Object.entries(entries).map(([k, v]) => [k, strToU8(v)])) + ); + const loaded = readOoxmlPackage(bytes); + if (!loaded.ok) throw Error(loaded.reason); + const part = loaded.package.parts.get(loaded.package.mainDocumentPart)!; + const fn = resolveFootnoteProperties(undefined, undefined); + const en = resolveEndnoteProperties(undefined, undefined); + const measurer = createFixedMeasurer(5, LINE); + const options = (session = createLayoutSession()) => ({ + measurer, + session, + notes: { + footnotesPart: resolveNotesPart(loaded.package, 'footnote'), + endnotesPart: null, + documentFootnoteProps: fn, + footnotePropsBySection: [fn], + documentEndnoteProps: en, + endnotePropsBySection: [en], + measurer, + producer: 'footnote-space-after-fit-test', + }, + }); + return { part, options }; +} + +function layout(body: string) { + const { part, options } = load(body); + return layoutSemanticDocument(part, 1, options()); +} + +const pageOf = (pages: readonly PageRecord[], text: string): number => + pages.findIndex((page) => + page.fragments.some( + (fragment) => + fragment.kind === 'paragraph' && + fragment.lines.some((line) => line.spans.some((span) => span.text.includes(text))) + ) + ); + +describe('space after above a footnote area', () => { + // A spacer pushes the contested line so `room` points remain between its box and the area. + const spacer = (room: number) => p('Spacer', 0, AREA_TOP - room - LINE); + + test('a citing line stays only when its space after fits too', () => { + const cited = (room: number, after: number) => + layout(spacer(room) + p('Cited', after)); + expect(pageOf(cited(5, 10).pages, 'Cited')).toBe(1); + expect(pageOf(cited(10, 10).pages, 'Cited')).toBe(0); + expect(pageOf(cited(1, 0).pages, 'Cited')).toBe(0); + }); + + test('a plain closing line above the notes needs its space after', () => { + const plain = (room: number, after: number) => + layout( + p('Opening') + + p('Spacer', 0, AREA_TOP - room - 2 * LINE) + + p('Closing', after) + + p('Next') + ); + expect(pageOf(plain(5, 10).pages, 'Closing')).toBe(1); + expect(pageOf(plain(12, 10).pages, 'Closing')).toBe(0); + }); + + test('a page without a citation keeps the space after out of the fit', () => { + const pages = layout( + p('Spacer', 0, PAGE - 2 * LINE) + + p('Closing', 10) + + p('Next') + ).pages; + expect(pageOf(pages, 'Closing')).toBe(0); + }); +}); + +test('a retained session matches a cold pass as a citing paragraph grows', () => { + // The citing opening paragraph wraps as it grows and pushes the closing paragraph down + // across its space-after threshold above the note area. + const doc = load( + p('Opening ') + + p('Spacer', 0, AREA_TOP - 20 - 2 * LINE) + + p('Closing', 10) + + p('Next') + ); + let part = doc.part; + const session = createLayoutSession(); + let result = layoutSemanticDocument(part, 1, doc.options(session)); + const opening = result.pages[0]!.fragments[0]!; + if (opening.kind !== 'paragraph') throw new Error('expected a paragraph'); + for (let index = 0; index < 50; index++) { + const edit = applyTreeOp(part, { + op: 'insertText', + paragraphId: opening.paragraphId, + offset: index, + text: 'w', + }); + if (!edit.ok) throw new Error(edit.reason); + part = edit.part; + result = layoutSemanticDocument(part, 2 + index, doc.options(session)); + expect(result.pages).toEqual(layoutSemanticDocument(part, 2 + index, doc.options()).pages); + } + expect(pageOf(result.pages, 'Closing')).toBe(1); +}); diff --git a/packages/core/src/layout/__tests__/legacy-cjk-line-metrics.test.ts b/packages/core/src/layout/__tests__/legacy-cjk-line-metrics.test.ts index 4fa93aa9a7..a6c4873fc0 100644 --- a/packages/core/src/layout/__tests__/legacy-cjk-line-metrics.test.ts +++ b/packages/core/src/layout/__tests__/legacy-cjk-line-metrics.test.ts @@ -77,10 +77,18 @@ test.each([0, 16, 21, 31])('other code pages do not add CJK leading (bit %s)', ( ); }); -test('CJK faces with an existing gap or taller face box keep those metrics', () => { +test('a one-em CJK face with a small gap takes the same 1.3em line', () => { + // SimSun declares a one-em box plus 36/256 em of leading; Word lays its 12pt lines + // 15.6pt apart, the same box as a face with no leading. + const metrics = measurer(18, 288).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 12 }); + expect(metrics.height).toBeCloseTo(12 * 1.3, 2); + expect(metrics.baseline).toBeCloseTo(12 * (1760 / 2048 + 0.15), 2); +}); + +test('CJK faces with a larger gap or taller face box keep those metrics', () => { expect( - measurer(17, 128).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 11 }).height - ).toBeCloseTo((11 * 2176) / 2048, 2); + measurer(17, 820).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 11 }).height + ).toBeCloseTo((11 * 2868) / 2048, 2); expect( measurer(17, 0, 2304).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 11 }).height ).toBeCloseTo((11 * 2592) / 2048, 2); diff --git a/packages/core/src/layout/__tests__/paragraph-space-shrink-terminal.test.ts b/packages/core/src/layout/__tests__/paragraph-space-shrink-terminal.test.ts index 35476eaf79..6194f87255 100644 --- a/packages/core/src/layout/__tests__/paragraph-space-shrink-terminal.test.ts +++ b/packages/core/src/layout/__tests__/paragraph-space-shrink-terminal.test.ts @@ -90,7 +90,7 @@ test('a last word split across styled runs, or followed by blank runs, still fit } }); -test('a hard break, trailing tab, or field result after or as the last word keeps the wrap', () => { +test('a hard break, trailing tab, or page field after or as the last word keeps the wrap', () => { const ref = (result: string) => `` + ` REF bm1 \\h ` + @@ -102,13 +102,19 @@ test('a hard break, trailing tab, or field result after or as the last word keep // The word keeps its own space, so only the break or tab after it can refuse it. 'aa bb cc ', 'aa bb cc ', - runs(['aa bb ']) + ref('cc'), runs(['aa bb ']) + `cc`, + // A page reference and a note reference can change width after the line is decided. + runs(['aa bb ']) + ref('cc').replace(' REF bm1 ', ' PAGEREF bm1 '), + runs(['aa bb ']) + ref('cc').replace(' REF bm1 ', ' NOTEREF bm1 '), ]) { const result = modern(paragraph(xml)); expect(content(result)[0]).toBe('aa bb'); expect(compressed(result)).toBe(false); } + // A cross-reference result keeps its width through pagination: it compresses like text. + const result = modern(paragraph(runs(['aa bb ']) + ref('cc'))); + expect(content(result)[0]).toBe('aa bb cc'); + expect(compressed(result)).toBe(true); }); test('no-break spaces are neither slots nor word ends for the last word', () => { diff --git a/packages/core/src/layout/__tests__/paragraph-space-shrink.test.ts b/packages/core/src/layout/__tests__/paragraph-space-shrink.test.ts index 703a3581ae..18106e0c6f 100644 --- a/packages/core/src/layout/__tests__/paragraph-space-shrink.test.ts +++ b/packages/core/src/layout/__tests__/paragraph-space-shrink.test.ts @@ -313,3 +313,36 @@ test('text after a center tab is placed against its stop, not compressed', () => content(layoutSemanticDocument(body, 1, { measurer, compatibilityMode: 15 })) ).toHaveLength(2); }); + +const justifiedLines = (text: string, width: number) => + content( + layoutSemanticDocument(source('both', text, width), 1, { measurer, compatibilityMode: 15 }) + ); + +test('a word joins the line when each space shrinks by at most half the stretch', () => { + const words = (count: number) => Array(count).fill('aa').join(' '); + // Each space would shrink by 0.22 of its width or stretch by 0.48: the word joins. + expect(justifiedLines(`${words(13)} bbb cc`, 330.35)[0]).toBe(`${words(13)} bbb`); + // Each space would shrink by 0.07 or stretch by only 0.12: the line stretches. + expect(justifiedLines(`${words(18)} b cc`, 437.05)[0]).toBe(words(18)); +}); + +test('a hyphen-break fragment borrows inter-word space', () => { + expect(justifiedLines('aa aa aa aa aa-bb cc', 124)[0]).toBe('aa aa aa aa aa-'); +}); + +test('a word with a currency sign lends its space', () => { + // The fit needs all four spaces' shrink, including the one after `$a`. + expect(justifiedLines('$a aa aa aa bb cc', 112)[0]).toBe('$a aa aa aa bb'); +}); + +test('every space after the last word hangs on the shorter line', () => { + // A run that opens with a space after a word that already has one, as around hidden + // deleted text: both spaces hang on the shorter line, so ten words stretch nine gaps. + const words = Array(10).fill('aa').join(' '); + const result = layoutSemanticDocument(seamSource([`${words} `, ' bb cc'], -1, 254), 1, { + measurer, + compatibilityMode: 15, + }); + expect(content(result)[0]).toBe(`${words} bb`); +}); diff --git a/packages/core/src/layout/__tests__/space-shrink-piece.test.ts b/packages/core/src/layout/__tests__/space-shrink-piece.test.ts new file mode 100644 index 0000000000..d078d20a21 --- /dev/null +++ b/packages/core/src/layout/__tests__/space-shrink-piece.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from 'bun:test'; +import type { FieldAwarePiece } from '../field-pieces.ts'; +import { isSpaceShrinkWordPiece } from '../space-shrink-piece.ts'; + +const piece = (extra: Partial): FieldAwarePiece => + ({ text: '3.4', props: [], style: {}, start: 10, end: 11, ...extra }) as FieldAwarePiece; + +test('a field shown by its saved result completes a justified word', () => { + expect( + isSpaceShrinkWordPiece( + piece({ projected: true, fieldAtom: { formField: false, fixedResult: true } }) + ) + ).toBe(true); +}); + +test('page-number and form fields keep their own width policy', () => { + expect( + isSpaceShrinkWordPiece( + piece({ projected: true, fieldAtom: { formField: false, pageField: { kind: 'PAGE' } } }) + ) + ).toBe(false); + expect(isSpaceShrinkWordPiece(piece({ projected: true, fieldAtom: { formField: true } }))).toBe( + false + ); + // A field result without the fixed-width mark (a body PAGE placeholder) stays out. + expect(isSpaceShrinkWordPiece(piece({ projected: true, fieldAtom: { formField: false } }))).toBe( + false + ); + // A projected unit that is neither a citation nor a field result stays out. + expect(isSpaceShrinkWordPiece(piece({ projected: true }))).toBe(false); +}); diff --git a/packages/core/src/layout/__tests__/table-auto-width-outer-box.test.ts b/packages/core/src/layout/__tests__/table-auto-width-outer-box.test.ts new file mode 100644 index 0000000000..4bb52d7d35 --- /dev/null +++ b/packages/core/src/layout/__tests__/table-auto-width-outer-box.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from 'bun:test'; +import { readOoxmlPart, serializeOoxmlPart } from '../../store/package/ooxml-tree.ts'; +import { applyTreeOp } from '../../store/store/tree-ops.ts'; +import { createLayoutSession } from '../layout-session.ts'; +import { createParagraphLayoutCache } from '../layout-cache.ts'; +import { createFixedMeasurer, layoutSemanticDocument } from '../semantic-layout.ts'; +import type { TableFragmentRecord } from '../semantic-records.ts'; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const edges = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']; + +/** An automatic-width table with 3pt rules whose grid fills the 468pt text column. */ +function source(columns: readonly number[], texts: readonly string[], jc: string) { + const cells = columns + .map( + (width, index) => + `${texts[index]}` + ) + .join(''); + const parsed = readOoxmlPart( + `` + + `${edges.map((edge) => ``).join('')}` + + `${columns.map((width) => ``).join('')}` + + `${cells}` + + '', + { name: '/word/document.xml', contentType: 'app/xml' } + ); + if (!parsed.ok) throw new Error(parsed.reason); + return parsed.part; +} + +const options = { measurer: createFixedMeasurer(6, 14), compatibilityMode: 15 }; + +function grid(columns: readonly number[], texts: readonly string[], jc: string) { + const layout = layoutSemanticDocument(source(columns, texts, jc), 1, options); + const table = layout.pages[0]!.fragments.find( + (fragment): fragment is TableFragmentRecord => fragment.kind === 'table' + )!; + const cellsBoxes = table.rows[0]!.cells.map((cell) => cell.box); + return cellsBoxes.map((box) => box.width); +} + +const total = (widths: readonly number[]) => widths.reduce((sum, width) => sum + width, 0); + +for (const jc of ['left', 'center', 'right']) { + test(`a ${jc}-aligned automatic-width table keeps its outer rules in the text column`, () => { + // The grid gives way by one 3pt rule: half of each outer rule sits inside the column. + const even = grid([3120, 3120, 3120], ['a', 'b', 'c'], jc); + expect(total(even)).toBeCloseTo(465, 6); + expect(even[0]!).toBeCloseTo(155, 6); + // A widened column settles against the same 465pt grid. + expect(total(grid([4000, 4360, 1000], ['a', 'b', 'x'.repeat(16)], jc))).toBeCloseTo(465, 6); + }); +} + +test('a grid far wider than the text column settles with its outer rules inside it', () => { + // Preferred cell widths spread over the text column; the outer box then fills it exactly. + expect(total(grid([4000, 4000, 4000], ['a', 'b', 'c'], 'left'))).toBeCloseTo(465, 6); +}); + +test('typing into a cell lays out like a cold pass', () => { + let part = source([4000, 4360, 1000], ['a', 'b', 'x'], 'left'); + const session = createLayoutSession(); + const cache = createParagraphLayoutCache(); + let layout = layoutSemanticDocument(part, 1, { ...options, session, cache }); + const table = layout.pages[0]!.fragments.find( + (fragment): fragment is TableFragmentRecord => fragment.kind === 'table' + )!; + const block = table.rows[0]!.cells[2]!.blocks[0]!; + if (block.kind !== 'paragraph') throw new Error('expected a paragraph'); + // Each character widens the third column until its word no longer fits the grid. + for (let index = 0; index < 20; index++) { + const edit = applyTreeOp(part, { + op: 'insertText', + paragraphId: block.paragraphId, + offset: 1 + index, + text: 'x', + }); + if (!edit.ok) throw new Error(edit.reason); + part = edit.part; + layout = layoutSemanticDocument(part, 2 + index, { ...options, session, cache }); + const cold = readOoxmlPart(serializeOoxmlPart(part), { + name: '/word/document.xml', + contentType: 'app/xml', + }); + if (!cold.ok) throw new Error(cold.reason); + expect(layout.pages).toEqual(layoutSemanticDocument(cold.part, 1, options).pages); + } +}); diff --git a/packages/core/src/layout/__tests__/table-autofit-deleted-content.test.ts b/packages/core/src/layout/__tests__/table-autofit-deleted-content.test.ts new file mode 100644 index 0000000000..284c8b29e3 --- /dev/null +++ b/packages/core/src/layout/__tests__/table-autofit-deleted-content.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'bun:test'; +import { readOoxmlPart } from '../../store/package/ooxml-tree.ts'; +import { createFixedMeasurer, layoutSemanticDocument } from '../semantic-layout.ts'; +import type { TableFragmentRecord } from '../semantic-records.ts'; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const LONG = 'x'.repeat(30); +const DEL = 'w:author="A" w:date="2026-01-01T00:00:00Z"'; +const cell = (content: string) => + `${content}`; +const run = (text: string) => `${text}`; +const deleted = (id: number, text: string) => + `${text}`; + +/** Column widths of a three-column automatic-width table with one extra row. */ +function widths(row: string): number[] { + const parsed = readOoxmlPart( + `` + + `${''.repeat(3)}` + + `${cell(run('a'))}${cell(run('b'))}${cell(run('c'))}${row}` + + '' + + '', + { name: '/word/document.xml', contentType: 'app/xml' } + ); + if (!parsed.ok) throw new Error(parsed.reason); + const layout = layoutSemanticDocument(parsed.part, 1, { + measurer: createFixedMeasurer(6, 14), + compatibilityMode: 15, + displayMode: 'proposed', + }); + const table = layout.pages[0]!.fragments.find( + (fragment): fragment is TableFragmentRecord => fragment.kind === 'table' + )!; + return table.rows[0]!.cells.map((cell) => cell.box.width); +} + +test('deleted content sizes autofit columns in a view that hides it', () => { + const plain = widths(`${cell(run('x'))}${cell(run('y'))}${cell(run(LONG))}`); + const deletedRow = widths( + `${cell(deleted(2, 'x'))}${cell(deleted(3, 'y'))}${cell(deleted(4, LONG))}` + ); + const deletedText = widths( + `${cell(run('x'))}${cell(run('y'))}${cell(run('z ') + deleted(5, LONG))}` + ); + expect(plain[2]!).toBeGreaterThan(156 + 1); + expect(deletedRow[2]!).toBeCloseTo(plain[2]!, 6); + expect(deletedText[2]!).toBeCloseTo(plain[2]!, 6); +}); diff --git a/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts b/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts new file mode 100644 index 0000000000..5c180291aa --- /dev/null +++ b/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'bun:test'; +import { readOoxmlPart } from '../../store/package/ooxml-tree.ts'; +import { + createFixedMeasurer, + createLayoutSession, + layoutSemanticDocument, +} from '../semantic-layout.ts'; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const paragraph = (text: string) => + `${text}`; +const edges = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']; + +/** Three one-line rows with 0.5pt rules on every edge and no cell margins. */ +function table(lastRowLines = 1) { + const parsed = readOoxmlPart( + ` + ${edges.map((edge) => ``).join('')} + ${['top', 'bottom', 'left', 'right'].map((s) => ``).join('')} + + ${['One', 'Two', 'Three'].map((text, index) => `${index === 2 ? Array.from({ length: lastRowLines }, (_, line) => paragraph(`${text}${line}`)).join('') : paragraph(text)}`).join('')} + `, + { name: '/word/document.xml', contentType: 'app/xml' } + ); + if (!parsed.ok) throw new Error(parsed.reason); + return parsed.part; +} + +function firstPageRows(height: number, lastRowLines = 1): string[] { + const result = layoutSemanticDocument(table(lastRowLines), 0, { + measurer: createFixedMeasurer(5, 12), + session: createLayoutSession(), + geometry: { width: 150, height, margin: { top: 0, right: 0, bottom: 0, left: 0 } }, + }); + return result.pages[0]!.fragments.flatMap((fragment) => + fragment.kind === 'table' + ? fragment.rows.flatMap((row) => + row.cells.flatMap((cell) => + cell.blocks.flatMap((block) => + block.kind === 'paragraph' + ? block.lines.map((line) => line.spans.map((span) => span.text).join('')) + : [] + ) + ) + ) + : [] + ); +} + +// Each row is a 0.5pt rule above a 12pt line; the page's last row also needs its own +// 0.5pt rule below it. +test('a row that ends the page needs room for its closing border', () => { + expect(firstPageRows(25.2)).toEqual(['One']); + expect(firstPageRows(25.5)).toEqual(['One', 'Two']); +}); + +test('a row followed on the page by another row keeps its place', () => { + expect(firstPageRows(38)).toEqual(['One', 'Two', 'Three0']); + // A two-line row that may split opens on the page, so the row above does not end it. + expect(firstPageRows(38, 2)).toEqual(['One', 'Two', 'Three0']); +}); + +test('a row stays when the next row opens below it, though a thick bottom rule would not fit', () => { + // The page-ending rule is the table's thick bottom edge; rows on one page share a thin + // inside rule. A splittable next row whose first line fits keeps the row above. + const borders = + '' + + '' + + ''; + const row = (content: string) => `${content}`; + const margins = ['top', 'bottom', 'left', 'right'] + .map((side) => ``) + .join(''); + const parsed = readOoxmlPart( + `${borders}` + + `${margins}` + + row(paragraph('One')) + + row(paragraph('Two')) + + row(paragraph('Three') + paragraph('More')) + + row(paragraph('Four')) + + '', + { name: '/word/document.xml', contentType: 'app/xml' } + ); + if (!parsed.ok) throw new Error(parsed.reason); + const rowsOnFirstPage = (height: number) => + layoutSemanticDocument(parsed.part, 0, { + measurer: createFixedMeasurer(5, 12), + session: createLayoutSession(), + geometry: { width: 150, height, margin: { top: 0, right: 0, bottom: 0, left: 0 } }, + }).pages[0]!.fragments.flatMap((fragment) => (fragment.kind === 'table' ? fragment.rows : [])) + .length; + // 12pt below `Two`: the next row cannot open, so `Two` ends the page and its thick rule + // does not fit. + expect(rowsOnFirstPage(37)).toBe(1); + // 13pt: `Three` opens with its first line, so `Two` stays. + expect(rowsOnFirstPage(38)).toBe(3); +}); diff --git a/packages/core/src/layout/__tests__/table-row-facts.test.ts b/packages/core/src/layout/__tests__/table-row-facts.test.ts index b7e85c847a..1c5b5d796e 100644 --- a/packages/core/src/layout/__tests__/table-row-facts.test.ts +++ b/packages/core/src/layout/__tests__/table-row-facts.test.ts @@ -32,7 +32,7 @@ const BORDERS = .join('') + ''; const documentXml = (rows: readonly string[]) => - `${BORDERS}` + + `${BORDERS}` + `${''.repeat(3)}${rows.join('')}` + '' + diff --git a/packages/core/src/layout/__tests__/table-width-moved-row-compare.test.ts b/packages/core/src/layout/__tests__/table-width-moved-row-compare.test.ts index 4267effec9..d5df2eaf5b 100644 --- a/packages/core/src/layout/__tests__/table-width-moved-row-compare.test.ts +++ b/packages/core/src/layout/__tests__/table-width-moved-row-compare.test.ts @@ -134,11 +134,11 @@ test('compound, thick and dashed rules with each page keeping its own bottom edg MIXED ) ); + // A page's last row publishes its own bottom rule. A row whose rule does not fit moves to + // the next page, so every moved row compares like the full walk (`typeBurst` checks it). typeBurst(part, paragraphAt(part, 16, 0), 'bottom '); expect(comparisons.accepted).toBeGreaterThan(0); - // The terminal placement and the ordinary one publish different bottom rules, so one of the - // two candidates refuses on the moved last row. - expect(comparisons.refused).toBeGreaterThan(0); + expect(comparisons.refused).toBe(0); }); test('mixed cell rules override the table rules on some rows', () => { diff --git a/packages/core/src/layout/__tests__/table-width-side-rules.test.ts b/packages/core/src/layout/__tests__/table-width-side-rules.test.ts index cb46dbcd76..6202d9b9c7 100644 --- a/packages/core/src/layout/__tests__/table-width-side-rules.test.ts +++ b/packages/core/src/layout/__tests__/table-width-side-rules.test.ts @@ -27,7 +27,7 @@ const BORDERS = .join('') + ''; const documentXml = (rows: readonly string[]) => - `${BORDERS}` + + `${BORDERS}` + `${''.repeat(3)}${rows.join('')}` + '' + diff --git a/packages/core/src/layout/__tests__/widened-cell-insets.test.ts b/packages/core/src/layout/__tests__/widened-cell-insets.test.ts index b23ee68190..eac82052e3 100644 --- a/packages/core/src/layout/__tests__/widened-cell-insets.test.ts +++ b/packages/core/src/layout/__tests__/widened-cell-insets.test.ts @@ -157,7 +157,7 @@ test('widening reuses its measured cells when the side-rule decision changes', ( bidiVisual: false, floating: false, cellSpacingPt: 0, - widthType: 'auto', + widthType: 'dxa', alignment: 'left', layoutFixed: false, indentPt: 0, diff --git a/packages/core/src/layout/field-pieces.ts b/packages/core/src/layout/field-pieces.ts index 390d50f979..fc18bcfb13 100644 --- a/packages/core/src/layout/field-pieces.ts +++ b/packages/core/src/layout/field-pieces.ts @@ -64,6 +64,12 @@ export interface PositionalTab { * view decision made downstream, so this states the fact and nothing about the appearance. */ export interface FieldAtomMarker { + /** + * A plain REF cross-reference with no nested field (not PAGEREF or NOTEREF): its result, + * the target's text or number, never changes with pagination, so a justified line may treat + * it as an ordinary word. + */ + readonly fixedResult?: true; /** * A legacy form field: `w:fldChar/w:ffData` (FORMTEXT, FORMCHECKBOX, FORMDROPDOWN). * diff --git a/packages/core/src/layout/field-projection-walk.ts b/packages/core/src/layout/field-projection-walk.ts index 6e25bc492d..080635b24b 100644 --- a/packages/core/src/layout/field-projection-walk.ts +++ b/packages/core/src/layout/field-projection-walk.ts @@ -70,6 +70,7 @@ import type { InlineDrawingLayoutContext } from './drawing-layout.ts'; import { isRunDrawingAtom, runDrawingAtomPlan } from './field-drawing-atom.ts'; import { legacyCheckboxAccessibleName } from '../store/package/legacy-checkbox-accessibility.ts'; import { legacyFormFieldDataOf } from '../store/package/field-nodes.ts'; +import { plainRefSpec } from './field-ref-parse.ts'; import { fieldProjectionSpansOf } from './field-projection-spans.ts'; import { markSavedResultPieces, savedSimpleResultLink } from './field-saved-result.ts'; import { @@ -267,7 +268,13 @@ export function unmergedPiecesOfParagraphForDisplay( carriedMemo = { ...captured, ...(carriedLink ? { linkOverride: carriedLink } : {}), - fieldAtom: { formField, ...(formControl ? { formControl } : {}) }, + fieldAtom: { + formField, + ...(formControl ? { formControl } : {}), + ...(pending?.refSpec && !pending.hasNestedField && plainRefSpec(pending.refSpec) + ? { fixedResult: true as const } + : {}), + }, }; return carriedMemo; }; diff --git a/packages/core/src/layout/field-ref-parse.ts b/packages/core/src/layout/field-ref-parse.ts index e5f6cbc263..75fb963056 100644 --- a/packages/core/src/layout/field-ref-parse.ts +++ b/packages/core/src/layout/field-ref-parse.ts @@ -180,3 +180,12 @@ function parsePageRefSwitches(tokens: readonly string[], bookmark: string): RefF refSpecModifiers.set(spec, REF_MODS_PAGEREF); return spec; } + +/** + * A plain REF: its result is the target's text or number, which pagination never changes. + * PAGEREF shows a page number and NOTEREF a note number that can restart on each page. + */ +export function plainRefSpec(spec: RefFieldSpec): boolean { + const modifiers = refSpecModifiersOf(spec); + return !modifiers.pageRef && !modifiers.noteRef; +} diff --git a/packages/core/src/layout/legacy-cjk-line-metrics.ts b/packages/core/src/layout/legacy-cjk-line-metrics.ts index fe3c58f060..556f8b429b 100644 --- a/packages/core/src/layout/legacy-cjk-line-metrics.ts +++ b/packages/core/src/layout/legacy-cjk-line-metrics.ts @@ -1,6 +1,6 @@ -// Legacy CJK faces can declare a one-em box with no leading. Word reserves extra -// space above and below those glyphs. Keep this narrow: faces with their own -// leading, taller face boxes, and explicit substitution metrics stay unchanged. +// Legacy CJK faces can declare a one-em box with little or no leading. Word reserves +// extra space above and below those glyphs. Keep this narrow: taller face boxes and +// explicit substitution metrics stay unchanged. import { trustedFontBytes, type ResolvedFont } from './font-resource.ts'; const cjkFaces = new WeakMap(); @@ -36,14 +36,13 @@ export function legacyCjkLineMetrics( lineGap: number, roundingTolerance: number ): { height: number; baseline: number } { - if ( - lineGap > roundingTolerance || - Math.abs(metrics.height - sizePt) > roundingTolerance || - !hasCjkCodePages(font) - ) - return metrics; - // Saved Word MS Gothic lines at 11pt occupy 14.28pt before paragraph spacing. - // The continuous 1.3em box differs only by Word's device-grid rounding. - const leading = sizePt * 0.3; - return { height: metrics.height + leading, baseline: metrics.baseline + leading / 2 }; + const faceBox = metrics.height - lineGap; + if (Math.abs(faceBox - sizePt) > roundingTolerance || !hasCjkCodePages(font)) return metrics; + // Saved Word MS Gothic lines at 11pt occupy 14.28pt before paragraph spacing, and + // SimSun lines at 12pt (a one-em box plus 0.14em of its own leading) occupy 15.6pt. + // Both are the continuous 1.3em box up to Word's device-grid rounding; the face's own + // leading is absorbed, and the extra space is split above and below the glyphs. + const height = Math.max(metrics.height, sizePt * 1.3); + const ascent = metrics.baseline - lineGap; + return { height, baseline: ascent + (height - faceBox) / 2 }; } diff --git a/packages/core/src/layout/legacy-table-side-rules.ts b/packages/core/src/layout/legacy-table-side-rules.ts index 1674c03b83..7790119f8e 100644 --- a/packages/core/src/layout/legacy-table-side-rules.ts +++ b/packages/core/src/layout/legacy-table-side-rules.ts @@ -234,11 +234,36 @@ function modernEdgeAlignedOffsetPt( let gridWidth = 0; for (const column of table.columnWidthsPt) gridWidth += column; const left = table.alignment === 'left'; - const fits = (left ? table.indentPt : 0) + gridWidth + rule <= table.containerWidthPt + 1e-6; + // AutoFit settles an automatic-width table with its outer box inside the container. + const fits = + (left ? table.indentPt : 0) + gridWidth + rule <= table.containerWidthPt + 1e-6 || + autoWidthOuterRulePt(rows, table) > 0; if (!fits && !(left && table.layoutFixed)) return undefined; return left ? rule / 2 : -rule / 2; } +/** + * The room a mode-15 automatic-width AutoFit table gives its outer side rules: AutoFit keeps + * the outer box, the grid plus half of each outer rule, inside the container for every + * alignment, so the grid settles one rule narrower. 0 for any other table. + */ +export function autoWidthOuterRulePt( + rows: readonly SemanticTableRow[], + table: SideRuleTableShape +): number { + if ( + !hasCompatibilityRule(table.compatibilityMode, 'modernGridLineSideRules') || + table.widthType !== 'auto' || + table.layoutFixed || + table.depth !== 0 || + table.bidiVisual || + table.floating || + table.cellSpacingPt !== 0 + ) + return 0; + return uniformSimpleSideRuleWidth(rows, table.columnWidthsPt.length) ?? 0; +} + /** * Admit simple collapsed side rules to the shared-grid-line geometry. * diff --git a/packages/core/src/layout/note-area-fit.ts b/packages/core/src/layout/note-area-fit.ts new file mode 100644 index 0000000000..72db489796 --- /dev/null +++ b/packages/core/src/layout/note-area-fit.ts @@ -0,0 +1,76 @@ +// Body fit above a footnote area: a paragraph's space after must fit there too. +import { fragmentCursorBottomPt } from './note-fragment-geometry.ts'; +import type { BlockFragmentRecord, LineRecord, PageRecord } from './semantic-records.ts'; + +type SpannedLine = Pick; + +function citesFootnote(line: SpannedLine): boolean { + return line.spans.some( + (span) => span.noteNav?.direction === 'to-note' && span.noteNav.scopeId.startsWith('footnote:') + ); +} + +/** + * Whether the page being filled has a footnote area: a footnote reference is already on + * it, or is on the paragraph line being placed. + * + * At the bottom of a plain page a paragraph's space after may cross the text margin. Above + * a footnote area it must fit, so the body pass charges the closing line with it. A note + * reserve alone does not prove an area: an eviction hold-out reserves room on a page + * whose own text cites nothing. + */ +export function pageHoldsFootnoteReference( + fragments: readonly BlockFragmentRecord[], + pending: readonly SpannedLine[], + line: SpannedLine +): boolean { + if (citesFootnote(line) || pending.some(citesFootnote)) return true; + return fragments.some(fragmentCitesFootnote); +} + +/** Fragments are immutable records, so each is scanned once per pass however often asked. */ +const fragmentCitations = new WeakMap(); +function fragmentCitesFootnote(fragment: BlockFragmentRecord): boolean { + let cites = fragmentCitations.get(fragment); + if (cites === undefined) { + cites = fragment.kind === 'paragraph' && fragment.lines.some(citesFootnote); + fragmentCitations.set(fragment, cites); + } + return cites; +} + +/** + * The space after the body pass charges to a paragraph's closing line: the paragraph's + * space after on a page that has a note reserve and cites a footnote, else 0. + */ +export function closingSpaceAfterPt( + reservePt: number | undefined, + fragments: readonly BlockFragmentRecord[], + pending: readonly SpannedLine[], + line: SpannedLine, + spaceAfterPt: number +): number { + if (spaceAfterPt <= 0 || (reservePt ?? 0) <= 0) return 0; + return pageHoldsFootnoteReference(fragments, pending, line) ? spaceAfterPt : 0; +} + +/** + * The space after that the body pass charges below the page's last line: the closing + * paragraph's space after when the page cites a footnote, else 0. A reserve that holds + * the body end in place must leave this room, or it pushes that paragraph out. + */ +export function chargedSpaceAfterPt(page: PageRecord): number { + let last: BlockFragmentRecord | undefined; + let lastBottom = -Infinity; + for (const fragment of page.fragments) { + const bottom = fragmentCursorBottomPt(fragment); + if (bottom > lastBottom) { + last = fragment; + lastBottom = bottom; + } + } + if (last?.kind !== 'paragraph' || !last.paragraphEnd) return 0; + // No reserve check: this answer only matters while a hold-out sizes this page's reserve, + // and a page it holds always has one. + return page.fragments.some(fragmentCitesFootnote) ? last.spacing.after : 0; +} diff --git a/packages/core/src/layout/note-fragment-geometry.ts b/packages/core/src/layout/note-fragment-geometry.ts index 7a1b0e5126..f77f842957 100644 --- a/packages/core/src/layout/note-fragment-geometry.ts +++ b/packages/core/src/layout/note-fragment-geometry.ts @@ -355,7 +355,11 @@ function referenceLineBand( for (const [index, line] of fragment.lines.entries()) { for (const segment of lineSegments(line)) { if (!segmentOwnsAtomOffset(segment, ref.paragraphId, ref.atomOffset)) continue; - return { top: line.box.y, bottom: line.box.y + line.box.height, index }; + // The paragraph's last line carries its space after: a reference there fits only + // when that space fits above the note area too. + const after = + fragment.paragraphEnd && index === fragment.lines.length - 1 ? fragment.spacing.after : 0; + return { top: line.box.y, bottom: line.box.y + line.box.height + after, index }; } } return null; diff --git a/packages/core/src/layout/note-reserve-holdout.ts b/packages/core/src/layout/note-reserve-holdout.ts index 33e9f9de8f..b02ca649a5 100644 --- a/packages/core/src/layout/note-reserve-holdout.ts +++ b/packages/core/src/layout/note-reserve-holdout.ts @@ -8,6 +8,7 @@ import { footnoteContinuationNotice } from './note-continuation-notice.ts'; import { splitNoteHead, splitNoteKeepsParagraphReference } from './note-eviction-guard.ts'; import { isOutOfFlowFragment } from './fragment-flow.ts'; +import { chargedSpaceAfterPt } from './note-area-fit.ts'; import { fragmentOwnsPosition } from './line-segments.ts'; import { bodyCursorBottomPt, @@ -155,7 +156,8 @@ export function holdOutReserveNeed(args: HoldOutArgs): number { const refLineHeight = frontier.bottom - frontier.top; - const bodyBottom = bodyCursorBottomPt(bodyPage); + // The closing paragraph's charged space after belongs to the body end here. + const bodyBottom = bodyCursorBottomPt(bodyPage) + chargedSpaceAfterPt(bodyPage); const contentHeight = bodyPage.contentBox.height; // A slack that cannot seat even the pulled line needs no reserve to stay out — and // publishing one for every naturally full page would churn the reserve fingerprints @@ -394,7 +396,15 @@ export function holdOutReserveNeed(args: HoldOutArgs): number { const headroom = admitsSplit ? 0 : refLineHeight; // Backed off by half a point like the eviction reserve: the last kept body line's bottom // is exactly `bodyBottom`, and a budget equal to it flips on float drift. - const hold = Math.max(0, contentHeight - bodyBottom - RESERVE_BOUNDARY_BACKOFF_PT); + const hold = leadingLinesHold( + args, + nextBody, + owningAt, + owningBlock, + frontier, + bodyBottom, + Math.max(0, contentHeight - bodyBottom - RESERVE_BOUNDARY_BACKOFF_PT) + ); const fitsLineQuantum = bodyBottom + splitOpeningHeight + headroom + areaWithPulled <= contentHeight + 0.001; if (!fitsLineQuantum) return hold; @@ -418,6 +428,56 @@ export function holdOutReserveNeed(args: HoldOutArgs): number { return neverOfferedTheRoom && !alreadyHeldHere ? 0 : hold; } +/** + * The hold, reduced so the lines ahead of the reference line can return without it. + * + * The reserve keeps the reference line out, not the whole opening. When the owning paragraph + * opens the next page and splits, the lines before the reference carry no note and return + * like any other line, as far as the slack and the paragraph's widow/orphan control allow: + * at least two lines stay with the reference when the paragraph ends there, and a paragraph + * that starts there returns at least two. The answer stops the body just below the last line + * that may return, so the reference line stays out. Without such lines it is `fullHold`. + */ +function leadingLinesHold( + args: HoldOutArgs, + nextBody: PageRecord, + owningAt: number, + owner: ParagraphFragmentRecord, + frontier: NoteReferenceLineBand, + bodyBottom: number, + fullHold: number +): number { + if (owningAt !== 0 || !stacksInOneColumn(args.bodyPage) || !stacksInOneColumn(nextBody)) { + return fullHold; + } + const keeps = paragraphKeeps(owner.props); + if (keeps.keepLines || owner.positionedFrame || owner.outOfFlow) return fullHold; + // Only a paragraph whose one footnote reference on this page is the frontier. With more, + // the returning lines move the later references' notes as well, and the reflow loop's + // settled answer for them is the full hold. + const cited = args + .pageBottomRefsOf(nextBody) + .filter((ref) => fragmentOwnsPosition(owner, ref.paragraphId, ref.atomOffset)); + if (cited.length !== 1) return fullHold; + const lines = owner.lines; + const referenceLine = lines.findIndex( + (line) => line.box.y + line.box.height > frontier.top + 0.001 + ); + let most = referenceLine < 0 ? 0 : referenceLine; + if (keeps.widowControl && owner.paragraphEnd) most = Math.min(most, lines.length - 2); + const least = keeps.widowControl && owner.fragmentIndex === 0 ? 2 : 1; + const contentHeight = args.bodyPage.contentBox.height; + const ownReserve = args.ownReservePt ?? args.existingAreaHeight; + const top = lines[0]!.box.y - (owner.fragmentIndex === 0 ? owner.spacing.before : 0); + for (let count = most; count >= least; count -= 1) { + const last = lines[count - 1]!; + const bottom = bodyBottom + last.box.y + last.box.height - top; + const reserve = contentHeight - bottom - RESERVE_BOUNDARY_BACKOFF_PT; + if (reserve >= ownReserve - 0.001) return Math.min(fullHold, Math.max(0, reserve)); + } + return fullHold; +} + /** The body opening that must return with a frontier reference. */ function heldOpening( fragments: PageRecord['fragments'], diff --git a/packages/core/src/layout/paragraph-flow.ts b/packages/core/src/layout/paragraph-flow.ts index 1878f3647f..c30704fa96 100644 --- a/packages/core/src/layout/paragraph-flow.ts +++ b/packages/core/src/layout/paragraph-flow.ts @@ -21,6 +21,7 @@ import { spacedLine } from './positioned-run-spacing.ts'; import { markRunPropertiesWithoutCharacterStyle } from './paragraph-mark-run.ts'; import { paragraphSpanMetadata } from './paragraph-span-metadata.ts'; import { + endsAtHyphenBreak, fitsWithSpaceShrink, opensWithHangingSpace, paragraphEndAt, @@ -1468,7 +1469,7 @@ export function breakParagraph( followingWidth !== undefined || endsParagraph(pieceIndex, boundary) || (boundary < piece.text.length - ? !layoutOwned && piece.text[boundary] === ' ' + ? !layoutOwned && (piece.text[boundary] === ' ' || endsAtHyphenBreak(candidate)) : opensWithHangingSpace(pieces[pieceIndex + 1])), followingWidth ?? 0 ); diff --git a/packages/core/src/layout/paragraph-space-shrink.ts b/packages/core/src/layout/paragraph-space-shrink.ts index a2040b407c..275e6c485d 100644 --- a/packages/core/src/layout/paragraph-space-shrink.ts +++ b/packages/core/src/layout/paragraph-space-shrink.ts @@ -19,6 +19,14 @@ const SPACED_SPAN = /^[\S\u2007\u202f]* $/u; const SPACED_WORD = new RegExp(`^${WORD_CHARACTER}+ $`, 'u'); const BARE_WORD = new RegExp(`^${WORD_CHARACTER}+$`, 'u'); +/** + * Whether a candidate ends at the break after a hyphen inside a word ("well-" of + * "well-known"). That fragment can borrow inter-word space as a whole word does. + */ +export function endsAtHyphenBreak(candidate: string): boolean { + return /\p{L}[-‐]$/u.test(candidate); +} + /** * The first span whose space can shrink. Only the text after a line's last tab is * justified; a tab and everything before it keep their positions. @@ -31,7 +39,8 @@ function firstShrinkSlot(spans: readonly StyleSpanRecord[]): number { function capacity(span: StyleSpanRecord, measurer: TextMeasurer): number { if ( - (!span.style.shaping && !/^[\p{Script=Latin}\p{N}\p{P} \u2007\u202f]*$/u.test(span.text)) || + (!span.style.shaping && + !/^[\p{Script=Latin}\p{N}\p{P}\p{Sc}\p{Sm} \u2007\u202f]*$/u.test(span.text)) || span.lineEndWhitespace || !SPACED_SPAN.test(span.text) || span.equation @@ -96,13 +105,20 @@ export function fitsWithSpaceShrink( // The alternative line stops before the overflowing word, so only the spans ahead // of it carry its stretch, and the space in front of it is the one that hangs. const before = spans.slice(first, Math.max(first, Math.min(wordStart, spans.length))); - const terminalSpace = before.length ? capacity(before[before.length - 1]!, measurer) * 4 : 0; + // Every space after the last word hangs there: a run of spaces as well as the word's own. + let terminalSpace = 0; + for (let index = before.length - 1; index >= 0; index -= 1) { + terminalSpace += capacity(before[index]!, measurer) * 4; + if (!/^ +$/.test(before[index]!.text)) break; + } const existingSpaces = before.reduce((sum, span) => sum + capacity(span, measurer) * 4, 0) - terminalSpace; if (existingSpaces <= 0) return false; const expansion = 1 + Math.max(0, available - wordStartWidth + terminalSpace) / existingSpaces; - const compression = spaceWidth / (spaceWidth - needed); - return expansion > 1.5 || 1 + (expansion - 1) / 1.7 >= compression; + // Shrinking a space costs twice as much as stretching one: the word joins the line + // when each space would lose at most half of what the shorter line would add to it. + const shrink = needed / spaceWidth; + return 2 * shrink <= expansion - 1 + 1e-9; } /** diff --git a/packages/core/src/layout/section-flow-keys.ts b/packages/core/src/layout/section-flow-keys.ts new file mode 100644 index 0000000000..16acfa5477 --- /dev/null +++ b/packages/core/src/layout/section-flow-keys.ts @@ -0,0 +1,83 @@ +// The section prepass's flow keys: each prepared block's own key, extended by every input a +// neighbour decides — positioned tables, paragraph frames, list auto spacing, leading break +// groups, contextual spacing, border groups, TOC verdicts, list markers, and keep chains. A +// block whose flow key is unchanged lays out the same at the same flow position. + +import { contextualFlowInputs } from './contextual-paragraph-spacing.ts'; +import type { ResolvedListItem } from './list-resolve.ts'; +import { listAutoSpacingFlowKeys } from './list-auto-spacing.ts'; +import { markerFlowToken } from './list-marker-reuse.ts'; +import { composeFlowKeys } from './pagination-keeps.ts'; +import { paragraphFrameFlowKeys } from './paragraph-frame-flow.ts'; +import { paragraphSectionNode } from './section-properties.ts'; +import type { PreparedBlock } from './section-prepass-types.ts'; +import type { StyleCascadeTable } from './style-cascade.ts'; +import { anchorFlowKeys, type PositionedTableFlowPolicy } from './table-float-exclusion.ts'; +import type { TableKeepDecisions } from './table-row-keeps.ts'; +import type { TerminalTextTableGroup } from './terminal-table-anchor.ts'; +import { tocVerdictFor, type TocIdSets } from './toc-id-sets.ts'; + +export interface SectionFlowKeyInputs { + readonly prepared: readonly PreparedBlock[]; + readonly keys: string[]; + readonly positioned: PositionedTableFlowPolicy; + readonly terminalTextTables: TerminalTextTableGroup | undefined; + readonly styleCascade: StyleCascadeTable | undefined; + readonly listItems: ReadonlyMap | undefined; + /** Empty when the part has no TOC field, which skips the TOC scan. */ + readonly tocToken: string; + readonly tocIds: TocIdSets; + readonly leadingBreakFlowKeys: (keys: string[], prepared: readonly PreparedBlock[]) => string[]; + readonly keptTables: Pick; +} + +/** The flow keys, plus the per-block keep-with-next flags and marker texts they read. */ +export function sectionFlowKeys(input: SectionFlowKeyInputs): { + readonly flowKeys: string[]; + readonly keepsNext: boolean[]; + readonly markerTexts: (string | undefined)[]; +} { + const { prepared, styleCascade, listItems, tocIds } = input; + const keepsNext = prepared.map((entry) => entry.kind === 'paragraph' && entry.keeps.keepNext); + const markerTexts = prepared.map((entry) => + entry.kind === 'paragraph' ? markerFlowToken(listItems?.get(entry.paragraph.id)) : undefined + ); + // A paragraph's bottom edge belongs to its border GROUP, which the block after it can + // join or leave. A table never groups, and neither does a paragraph with no borders. + const borderGroupKeys = prepared.map((entry) => + entry.kind === 'paragraph' ? entry.borderGroupKey : '' + ); + // Which lines a TOC field's paragraphs emit at all, decided by the OTHER paragraphs of + // the same field. A part with no TOC skips the scan outright rather than asking three + // empty sets about every block it holds. + const tocVerdicts = + input.tocToken === '' + ? [] + : prepared.map((entry) => + entry.kind === 'paragraph' ? tocVerdictFor(entry.paragraph.id, tocIds) : '' + ); + + const lastBlock = prepared.at(-1); + const flowKeys = composeFlowKeys( + input.leadingBreakFlowKeys( + listAutoSpacingFlowKeys( + paragraphFrameFlowKeys(anchorFlowKeys(input.keys, input.positioned), prepared), + prepared + ), + prepared + ), + { + terminalTableGroup: input.terminalTextTables, + ...contextualFlowInputs(prepared, styleCascade), + borderGroupKeyAt: (index) => borderGroupKeys[index]!, + tocVerdicts, + markerTextAt: (index) => markerTexts[index], + keepsNextAt: (index) => keepsNext[index]! || input.keptTables.endsKept(prepared[index]), + tableAt: (index) => prepared[index]?.kind === 'table', + endsWithSectionMark: + lastBlock?.kind === 'paragraph' && !!paragraphSectionNode(lastBlock.paragraph), + skipKeepNextAt: (index) => prepared[index]?.kind === 'paragraph' && !!prepared[index].frame, + } + ); + return { flowKeys, keepsNext, markerTexts }; +} diff --git a/packages/core/src/layout/semantic-layout.ts b/packages/core/src/layout/semantic-layout.ts index 8fcabf3ddd..b898fae87d 100644 --- a/packages/core/src/layout/semantic-layout.ts +++ b/packages/core/src/layout/semantic-layout.ts @@ -14,11 +14,7 @@ import { layoutWithCharacterHeaders, layoutWithCharacterHeadersSteps, } from './character-header-layout.ts'; -import { - contextualFlowInputs, - contextualParagraphSpacing, - flowNeighbourStyle, -} from './contextual-paragraph-spacing.ts'; +import { contextualParagraphSpacing, flowNeighbourStyle } from './contextual-paragraph-spacing.ts'; import { resolveParagraphFrame } from './paragraph-drop-cap.ts'; import { anchorLineSkipsExclusion, @@ -33,13 +29,13 @@ import { } from './furniture-drawing-exclusion.ts'; import { tocLinkRanges, tocLinkStyleToken } from './toc-link-formatting.ts'; import { tocCodeRanges } from './field-code-toc.ts'; -import { tocIdsToken, tocVerdictFor, type TocIdSets } from './toc-id-sets.ts'; +import { tocIdsToken, type TocIdSets } from './toc-id-sets.ts'; import { paragraphIsRtl } from './rtl-paragraph.ts'; import * as sectionPrep from './section-preparation.ts'; -import { resolveListAutoSpacing, listAutoSpacingFlowKeys } from './list-auto-spacing.ts'; +import { resolveListAutoSpacing } from './list-auto-spacing.ts'; import { emptyParagraphStyleFields, markStyleOf } from './empty-paragraph-style.ts'; import { frameOrigins, positionedFrameBottom } from './paragraph-frame.ts'; -import { ParagraphFrameFlow, paragraphFrameFlowKeys } from './paragraph-frame-flow.ts'; +import { ParagraphFrameFlow } from './paragraph-frame-flow.ts'; // Semantic paragraph layout over the canonical tree (tasks 7.1, 7.3). // // Produces the revision-tagged records in `semantic-records.ts`: pages, paragraph fragments, @@ -87,7 +83,6 @@ import { } from './paragraph-style.ts'; import { adjustedBreakIndex, - composeFlowKeys, keepNextChains, markJoinFlowKeys, paragraphKeeps, @@ -192,6 +187,7 @@ import { layoutSemanticDocumentWithNotes, notesReserveContextKey, } from './note-pagination.ts'; +import { closingSpaceAfterPt } from './note-area-fit.ts'; import { passProducerOf } from './pass-producer.ts'; import { documentProjectionProducer } from './document-property-context.ts'; @@ -237,6 +233,7 @@ export { type HeaderFooterVariantName, type PageFurniture } from './page-furnitu import type { SemanticLayoutOptions } from './semantic-layout-options.ts'; export type { SemanticLayoutOptions } from './semantic-layout-options.ts'; import type { PreparedBlock, SectionPrepass } from './section-prepass-types.ts'; +import { sectionFlowKeys } from './section-flow-keys.ts'; export type { SectionPrepass } from './section-prepass-types.ts'; export type BlockLayoutOptions = ColumnBalanceBlockLayoutOptions & { @@ -680,6 +677,7 @@ function* layoutBlocksPass( // IS the previous section's last sheet: both passes read the same document slot, so // every flow sharing the sheet stops above the same note area. contentHeightOf(pageBottomReserves?.get(pageIndexStart + index) ?? 0, index); + const noteReserveHere = () => pageBottomReserves?.get(pageIndexStart + pages.length); /** The same band with the footnote reserve ignored — the table paginator's recovery. */ const unreservedContentHeight = (): number => contentHeightOf(0); @@ -1002,47 +1000,22 @@ function* layoutBlocksPass( authorFilter, options.compatibilityMode ); - const keepsNext = prepared.map((entry) => entry.kind === 'paragraph' && entry.keeps.keepNext); - const markerTexts = prepared.map((entry) => - entry.kind === 'paragraph' ? markerFlowToken(listItems?.get(entry.paragraph.id)) : undefined - ); - // A paragraph's bottom edge belongs to its border GROUP, which the block after it can - // join or leave. A table never groups, and neither does a paragraph with no borders. - const borderGroupKeys = prepared.map((entry) => - entry.kind === 'paragraph' ? entry.borderGroupKey : '' - ); - // Which lines a TOC field's paragraphs emit at all, decided by the OTHER paragraphs of - // the same field. A part with no TOC skips the scan outright rather than asking three - // empty sets about every block it holds. - const tocVerdicts = - tocToken === '' - ? [] - : prepared.map((entry) => - entry.kind === 'paragraph' ? tocVerdictFor(entry.paragraph.id, tocIds) : '' - ); - - const lastBlock = prepared.at(-1); - const flow = composeFlowKeys( - leadingBreakGroups.flowKeys( - listAutoSpacingFlowKeys( - paragraphFrameFlowKeys(tableWrap.anchorFlowKeys(keys, positioned), prepared), - prepared - ), - prepared - ), - { - terminalTableGroup: terminalTextTables, - ...contextualFlowInputs(prepared, styleCascade), - borderGroupKeyAt: (index) => borderGroupKeys[index]!, - tocVerdicts, - markerTextAt: (index) => markerTexts[index], - keepsNextAt: (index) => keepsNext[index]! || keptTables.endsKept(prepared[index]), - tableAt: (index) => prepared[index]?.kind === 'table', - endsWithSectionMark: - lastBlock?.kind === 'paragraph' && !!paragraphSectionNode(lastBlock.paragraph), - skipKeepNextAt: (index) => prepared[index]?.kind === 'paragraph' && !!prepared[index].frame, - } - ); + const { + flowKeys: flow, + keepsNext, + markerTexts, + } = sectionFlowKeys({ + prepared, + keys, + positioned, + terminalTextTables, + styleCascade, + listItems, + tocToken, + tocIds, + leadingBreakFlowKeys: leadingBreakGroups.flowKeys, + keptTables, + }); return { framePolicy, @@ -2222,6 +2195,8 @@ function* layoutBlocksPass( // Place the lines, fragmenting at page boundaries. let fragmentIndex = 0; let pending: LineRecord[] = []; + const closingAfter = (line: PendingLine): number => + closingSpaceAfterPt(noteReserveHere(), pageFragments, pending, line, spacing.after); let fragmentStart = lines[0]?.start ?? 0; let fragmentBefore = appliedBefore; // Reserved above the FIRST fragment only: a paragraph continued onto the next page opens @@ -2503,8 +2478,8 @@ function* layoutBlocksPass( const isLastLine = lineIndex === lines.length - 1; // Spacing-after stays out of the fit budget (see the firstTail note above): it moves // where the NEXT paragraph starts, never whether this line fits, and it clips at the - // page boundary rather than carrying over. - const tail = isLastLine ? borderExtent : 0; + // page boundary rather than carrying over. Above a footnote area it must fit too. + const tail = isLastLine ? borderExtent + closingAfter(pendingLine) : 0; if (lineIndex === fragmentFirstLine) { fragmentParagraphStartY = cursorY; if (paragraphHasAnchors && paragraphAnchorOrigin === null && fragmentIndex === 0) { diff --git a/packages/core/src/layout/semantic-table.ts b/packages/core/src/layout/semantic-table.ts index a3356264e8..4a8ea445d5 100644 --- a/packages/core/src/layout/semantic-table.ts +++ b/packages/core/src/layout/semantic-table.ts @@ -1,4 +1,5 @@ import { cachedTableStructure } from './table-structure-cache.ts'; +import { tableHasHidableRevisions } from './table-revision-markup.ts'; import { revisionCellMetadata, wmlRevisionChild, @@ -6,6 +7,7 @@ import { } from './revision-cell-shading.ts'; import { readTableAlignment } from './table-alignment.ts'; import { + autoWidthOuterRulePt, withSharedGridLineSideRules, retargetSharedGridLineSideRules, } from './legacy-table-side-rules.ts'; @@ -306,6 +308,11 @@ export interface SemanticTableStructure { * on that edge, so its first grid line sits half a rule inward. Positive moves right. */ readonly outerRuleOffsetPt?: number; + /** + * Derived, never serialized: the outer side rule an automatic-width mode-15 AutoFit table + * keeps inside its container, so its grid settles that much narrower than the room. + */ + readonly autoOuterRulePt?: number; /** `w:tblPr/w:tblW` — the width the table asked for. */ readonly tableWidth: PreferredWidth; /** @@ -405,12 +412,28 @@ export function readTableStructure( ) ); if (!autofit || !base || (base.layoutFixed && depth === 0)) return base; + // Deleted and inserted content sizes the columns in every view. + const markupStructure = + displayMode === 'all-markup' || !tableHasHidableRevisions(table) + ? undefined + : readTableStructure( + table, + contentWidthPt, + depth, + styleCascade, + 'all-markup', + authorFilter, + compatibilityMode, + undefined, + textBox + ); // Most tables already hold their content: they come back as the shared base structure. const widths = autofitColumnWidthsPt(base, contentWidthPt, autofit, { styleCascade, displayMode, authorFilter, depth, + ...(markupStructure ? { markupStructure } : {}), readNested: narrowNestedReader( readTableStructure, styleCascade, @@ -900,6 +923,8 @@ function readTableStructureUncached( : sideRules.rows; const outerRuleOffsetPt = contentOffset ?? sideRules.outerRuleOffsetPt ?? placement.bidiRuleShiftPt; + const autoOuterRulePt = + contentOffset === undefined ? autoWidthOuterRulePt(contentRows, sideRuleShape) : 0; return { ...(bidiVisual ? { bidiVisual: true as const } : {}), columnWidthsPt: bidiVisual ? [...columnWidthsPt].reverse() : columnWidthsPt, @@ -913,6 +938,7 @@ function readTableStructureUncached( ...(legacyWidth === undefined ? {} : { legacyContentAlignment: true as const }), ...(legacy?.trailingOuterEdge ? { legacyTrailingOuterEdge: true as const } : {}), ...(outerRuleOffsetPt === undefined ? {} : { outerRuleOffsetPt }), + ...(autoOuterRulePt > 0 ? { autoOuterRulePt } : {}), tableWidth, layoutFixed, indentPt, diff --git a/packages/core/src/layout/space-shrink-piece.ts b/packages/core/src/layout/space-shrink-piece.ts index 839886e2ac..20cb12a4da 100644 --- a/packages/core/src/layout/space-shrink-piece.ts +++ b/packages/core/src/layout/space-shrink-piece.ts @@ -2,6 +2,7 @@ import type { FieldAwarePiece } from './field-pieces.ts'; /** Text with a stable measured advance that can complete a space-compressed word. */ export function isSpaceShrinkWordPiece(piece: FieldAwarePiece): boolean { + const field = piece.fieldAtom; if ( piece.positionalTab || piece.measureText !== undefined || @@ -9,18 +10,21 @@ export function isSpaceShrinkWordPiece(piece: FieldAwarePiece): boolean { piece.anchoredAtom || piece.equation || piece.noteSeparator || - piece.fieldAtom + // Form fields and page-number fields change after the line is decided. + (field && !field.fixedResult) ) return false; if (piece.projected) { // Body citations keep their atomic range, even when their display has several digits. - // Reserved page-local marks and computed field values retain their existing policy. + // A field shown by its saved result (a cross-reference number) has a fixed width too. + // Reserved page-local marks retain their existing policy. return ( - piece.noteNav?.direction === 'to-note' && + (piece.noteNav?.direction === 'to-note' || + (piece.noteNav === undefined && field?.fixedResult === true)) && piece.end - piece.start === 1 && piece.text.length > 0 && !/\s/u.test(piece.text) ); } - return piece.end - piece.start === piece.text.length; + return field === undefined && piece.end - piece.start === piece.text.length; } diff --git a/packages/core/src/layout/table-autofit-widths.ts b/packages/core/src/layout/table-autofit-widths.ts index a583364c11..4033993e42 100644 --- a/packages/core/src/layout/table-autofit-widths.ts +++ b/packages/core/src/layout/table-autofit-widths.ts @@ -122,6 +122,11 @@ export interface AutofitView { * the nesting limit, where layout paints nothing either. See {@link narrowNestedReader}. */ readonly readNested: (table: OoxmlElement, depth: number) => SemanticTableStructure | null; + /** + * The same table read with every revision shown. Column widths size for deleted content + * too, whatever the view shows, so a view that hides revisions measures this structure. + */ + readonly markupStructure?: SemanticTableStructure; } /** Below this a column is already as wide as its content needs. */ @@ -672,7 +677,10 @@ export function autofitColumnMinimumsPt( }); const { least, most } = measured; if (least < 0) continue; - const around = measured.wideLeft + measured.wideRight; + // Widening an automatic-width table keeps its shared grid lines (`autoOuterRulePt`). + const around = structure.autoOuterRulePt + ? measured.left + measured.right + : measured.wideLeft + measured.wideRight; if (cell.gridSpan !== 1) { // A spanning cell also covers the gaps between the columns it spans. const covered = (cell.gridSpan - 1) * gapPt; @@ -736,13 +744,20 @@ export function widenAutofitColumns( widths: readonly number[], minimums: readonly number[], targetPt: number, - availablePt: number + availablePt: number, + /** Also bring a table wider than `targetPt` down to it when no column widens. */ + shrinkToTarget = false ): readonly number[] { let deficit = 0; + let total = 0; for (const [index, width] of widths.entries()) { deficit += Math.max(0, minimums[index]! - width); + total += width; } - if (deficit <= WIDTH_EPSILON_PT) return widths; + if (deficit <= WIDTH_EPSILON_PT && !(shrinkToTarget && total > targetPt + WIDTH_EPSILON_PT)) + return widths; + // Nothing widens: every column gives way in proportion to its width. + if (deficit <= WIDTH_EPSILON_PT) return widths.map((width) => (width * targetPt) / total); const grown = widths.map((width, index) => Math.max(width, minimums[index]!)); const need = grown.reduce((sum, width) => sum + width, 0) - targetPt; if (need <= WIDTH_EPSILON_PT) return grown; @@ -836,6 +851,29 @@ export function autofitColumnWidthsPt( const ifWidened: number[] = []; const content = emptyColumnContent(); const minimumsNow = autofitColumnMinimumsPt(structure, context, view, ifWidened, content); + // Deleted content widens a column too: take the larger need of the two readings. + const markup = view.markupStructure; + if (markup && markup.columnWidthsPt.length === structure.columnWidthsPt.length) { + const markupWidened: number[] = []; + const markupContent = emptyColumnContent(); + const markupView: AutofitView = { ...view, displayMode: 'all-markup' }; + const markupMinimums = autofitColumnMinimumsPt( + markup, + context, + markupView, + markupWidened, + markupContent + ); + for (const [column, minimum] of markupMinimums.entries()) { + if (!markupContent.measured[column]) continue; + minimumsNow[column] = Math.max(minimumsNow[column]!, minimum); + ifWidened[column] = Math.max(ifWidened[column]!, markupWidened[column]!); + content.maximums[column] = Math.max( + content.maximums[column]!, + markupContent.maximums[column]! + ); + } + } // Everything below works on cell widths; a spaced table's gaps come back at the end. const cellWidths = spacedCellWidths(structure); const scale = cellSpacingScale(structure.columnWidthsPt, structure.cellSpacingPt); @@ -853,6 +891,9 @@ export function autofitColumnWidthsPt( content.spans.map((span) => ({ ...span, minimum: span.current ?? span.minimum })) ); const sizedByContent = content.sizedByContent.some(Boolean); + // An automatic-width table keeps its outer side rules inside the text column. + const roomWidthPt = contentWidthPt - (structure.autoOuterRulePt ?? 0); + const availableFor = (room: number): number => Math.max(0, room - leadingIndentPt(structure)); const widens = current.some( (minimum, column) => minimum > cellWidths[column]! + WIDTH_EPSILON_PT ); @@ -863,11 +904,22 @@ export function autofitColumnWidthsPt( structure, content, widens && !structure.legacyContentAlignment ? ifWidened : minimumsNow, - contentWidthPt, + roomWidthPt, view.depth ?? 0 ); if (spread || (!widens && !sizedByContent)) { - const widths = spread ? sameOrComputed(structure, spread) : structure.columnWidthsPt; + // A grid that fills the text column gives way by its outer rule, never further. + const rule = structure.autoOuterRulePt ?? 0; + const settledPt = cellWidths.reduce((sum, width) => sum + width, 0); + const shrinkTo = Math.max(availableFor(roomWidthPt), settledPt - rule); + const widths = spread + ? sameOrComputed(structure, spread) + : rule > 0 + ? sameOrComputed( + structure, + widenAutofitColumns(cellWidths, minimumsNow, shrinkTo, Infinity, true) + ) + : structure.columnWidthsPt; byStructure.set(structure, { contentWidthPt, widths }); return widths; } @@ -876,9 +928,7 @@ export function autofitColumnWidthsPt( // content-aligned table owns the room its own width already spans. const totalPt = structure.columnWidthsPt.reduce((sum, width) => sum + width, 0); const legacy = structure.legacyContentAlignment === true; - const availablePt = legacy - ? Math.max(totalPt, contentWidthPt) - : Math.max(0, contentWidthPt - leadingIndentPt(structure)); + const availablePt = legacy ? Math.max(totalPt, contentWidthPt) : availableFor(roomWidthPt); const targetPt = autofitTargetPt(structure.tableWidth, totalPt, availablePt, legacy, contentWidthPt) - gaps; const roomPt = Math.max(0, availablePt - gaps); diff --git a/packages/core/src/layout/table-flow-pagination.ts b/packages/core/src/layout/table-flow-pagination.ts index 33fb3cf957..ed3a8e6821 100644 --- a/packages/core/src/layout/table-flow-pagination.ts +++ b/packages/core/src/layout/table-flow-pagination.ts @@ -13,7 +13,6 @@ import { clippedExactRow, overheightRowError } from './table-exact-row-clip.ts'; // row publishes into. Everything the paginator needs to mutate is on that object, so the // story loop keeps ownership of the cursor and this module keeps the row rules. -import { autofitContextOf } from './table-autofit-widths.ts'; import { positionedTableOriginX } from './table-origin.ts'; import { withoutFloatingTableZones } from './table-float-overlap.ts'; import type { OoxmlElement } from '@docx-editor.dev/core/store'; @@ -29,7 +28,11 @@ import { } from './semantic-table-layout.ts'; import { closingCellSpacingPt } from './table-cell-spacing.ts'; import { vMergePlanFor } from './table-vmerge-plan.ts'; -import { prepareTerminalBorderPlan } from './table-terminal-border-plan.ts'; +import { + closingEdgePt, + terminalBorderPlanAt, + type TerminalRowAt, +} from './table-terminal-border-plan.ts'; import { firstRowContentDeps } from './table-fragment-content-insets.ts'; import { probeRowFragmentProgress } from './table-row-progress-probe.ts'; import { bottomToTopTextKeepsRowWhole } from './table-cell-text-direction.ts'; @@ -50,8 +53,8 @@ import { type VMergeFragmentCarry, } from './table-vmerge-fragment-carry.ts'; import { annotateTableFragmentGeometry } from './semantic-table-interaction.ts'; -import { readTableStructure, tableOriginX, type SemanticTableRow } from './semantic-table.ts'; -import { pinnedBreakAtCursor, withSplittableRows } from './table-pinned-break.ts'; +import { tableOriginX, type SemanticTableRow } from './semantic-table.ts'; +import { readFlowTable } from './table-flow-structure.ts'; import { tableFloatOriginY } from './table-float-position.ts'; import { shiftTableFragment, splitHead } from './table-fragment-finalize.ts'; import { budgetsHaveHeadroom, withBudgetProof } from './table-budget-proof.ts'; @@ -93,36 +96,14 @@ export function* paginateTableInFlowSteps( anchorFrames, verticalAnchorFrames, styleCascade, - displayMode, - revisionAuthorFilter, deps: flowDeps, shiftAnchor, publishFragment, } = flow; - const regionWidth = columnWidth(); - const authored = readTableStructure( - table, - regionWidth, - 0, - styleCascade, - displayMode, - revisionAuthorFilter, - flow.compatibilityMode, - autofitContextOf(flowDeps) - ); - if (!authored || authored.rows.length === 0) return { outOfFlow: false }; - // A page- or margin-positioned table that reaches below the bottom margin breaks across - // pages in the body flow instead of keeping its sheet position (`table-pinned-break.ts`). - const pinnedBreak = flow.positionTextTable - ? undefined - : pinnedBreakAtCursor(table, authored, flow); - const structure = pinnedBreak ? withSplittableRows(authored) : authored; + const read = readFlowTable(table, flow); + if (!read) return { outOfFlow: false }; + const { structure, pinnedBreak, outOfFlow } = read; if (pinnedBreak) flow.cursorY = pinnedBreak.top; - const outOfFlow = - pinnedBreak === undefined && - structure.float !== undefined && - (structure.float.vertAnchor !== 'text' || flow.positionTextTable === true) && - structure.float.ySpec !== 'inline'; // Only a table without `w:tblpPr` honors `w:pageBreakBefore` rows (`table-row-page-break.ts`). const breaksPages = structure.float === undefined; if ( @@ -642,22 +623,24 @@ export function* paginateTableInFlowSteps( if (rows.length === 0 && clearance.clear(clearance.pending)) admitSpans(bodyRowIndex, row); } + const terminalAt = (): TerminalRowAt => ({ + structure, + row, + left: tableLeft, + top: flow.cursorY, + bottom: contentHeight(), + deps: rowDeps(), + }); + // The room a row that ends the page needs below it for its own closing border. + let closingEdge = 0; const tryTerminalFit = (): void => { - if ( - terminalDeps || - isContinuation || - heldByOpenSpan || - naturalHeight <= contentHeight() - flow.cursorY + 0.001 - ) + if (terminalDeps || isContinuation || heldByOpenSpan) return; + if (naturalHeight <= contentHeight() - flow.cursorY + 0.001) { + const next = bodyRows[bodyRowIndex + 1]; + closingEdge = closingEdgePt(terminalAt(), naturalHeight, next, tableDeps, rowHeightOf); return; - const plan = prepareTerminalBorderPlan( - structure, - row, - tableLeft, - flow.cursorY, - contentHeight(), - rowDeps() - ); + } + const plan = terminalBorderPlanAt(terminalAt()); if (!plan) return; terminalDeps = plan.deps; naturalHeight = plan.height; @@ -743,7 +726,7 @@ export function* paginateTableInFlowSteps( })) || (!heldByOpenSpan && naturalHeight <= contentHeight() + 0.001 && - flow.cursorY + naturalHeight > contentHeight() + 0.001 && + flow.cursorY + naturalHeight + closingEdge > contentHeight() + 0.001 && flow.cursorY > 0 && (keepsWholeAt(flow.cursorY) || row.height.rule === 'exact' || @@ -752,7 +735,7 @@ export function* paginateTableInFlowSteps( structure.columnWidthsPt, tableLeft, flow.cursorY, - contentHeight(), + contentHeight() - closingEdge, false, 0, rowDeps(), diff --git a/packages/core/src/layout/table-flow-structure.ts b/packages/core/src/layout/table-flow-structure.ts new file mode 100644 index 0000000000..36366d5ffd --- /dev/null +++ b/packages/core/src/layout/table-flow-structure.ts @@ -0,0 +1,47 @@ +// The table a body flow paginates: its structure at the column width, the page- or +// margin-positioned break that moves it into the flow, and whether it stays out of flow. + +import type { OoxmlElement } from '@docx-editor.dev/core/store'; +import { readTableStructure, type SemanticTableStructure } from './semantic-table.ts'; +import { autofitContextOf } from './table-autofit-widths.ts'; +import type { TableFlowCursor } from './table-flow-cursor.ts'; +import { + pinnedBreakAtCursor, + withSplittableRows, + type PinnedTableBreak, +} from './table-pinned-break.ts'; + +export interface FlowTable { + readonly structure: SemanticTableStructure; + /** Set when a positioned table reaching below the bottom margin breaks in the body flow. */ + readonly pinnedBreak: PinnedTableBreak | undefined; + /** A positioned table that keeps its sheet position. */ + readonly outOfFlow: boolean; +} + +/** Read `table` for the flow at its column width; undefined when it has no rows. */ +export function readFlowTable(table: OoxmlElement, flow: TableFlowCursor): FlowTable | undefined { + const authored = readTableStructure( + table, + flow.columnWidth(), + 0, + flow.styleCascade, + flow.displayMode, + flow.revisionAuthorFilter, + flow.compatibilityMode, + autofitContextOf(flow.deps) + ); + if (!authored || authored.rows.length === 0) return undefined; + // A page- or margin-positioned table that reaches below the bottom margin breaks across + // pages in the body flow instead of keeping its sheet position (`table-pinned-break.ts`). + const pinnedBreak = flow.positionTextTable + ? undefined + : pinnedBreakAtCursor(table, authored, flow); + const structure = pinnedBreak ? withSplittableRows(authored) : authored; + const outOfFlow = + pinnedBreak === undefined && + structure.float !== undefined && + (structure.float.vertAnchor !== 'text' || flow.positionTextTable === true) && + structure.float.ySpec !== 'inline'; + return { structure, pinnedBreak, outOfFlow }; +} diff --git a/packages/core/src/layout/table-revision-markup.ts b/packages/core/src/layout/table-revision-markup.ts new file mode 100644 index 0000000000..8c49a3819d --- /dev/null +++ b/packages/core/src/layout/table-revision-markup.ts @@ -0,0 +1,28 @@ +// Whether a table holds tracked content that a view can hide. +import type { OoxmlNode } from '@docx-editor.dev/core/store'; + +/** Elements whose content one of the revision views hides. */ +const HIDABLE = new Set(['del', 'ins', 'moveFrom', 'moveTo', 'cellDel', 'cellIns']); + +const known = new WeakMap(); + +/** + * Whether `table` contains inserted, deleted or moved content. Only such a table can size its + * columns differently with every revision shown, so only it needs a second reading. Memoized + * per node: an untouched table keeps its node identity across edits. + */ +export function tableHasHidableRevisions(table: OoxmlNode): boolean { + if (table.kind === 'textValue') return false; + const cached = known.get(table); + if (cached !== undefined) return cached; + const pending: OoxmlNode[] = [table]; + let found = false; + while (pending.length > 0 && !found) { + const node = pending.pop()!; + if (node.kind === 'textValue') continue; + if (node !== table && HIDABLE.has(node.localName)) found = true; + else for (const child of node.children) pending.push(child); + } + known.set(table, found); + return found; +} diff --git a/packages/core/src/layout/table-terminal-border-plan.ts b/packages/core/src/layout/table-terminal-border-plan.ts index 20c4fea46b..0b3e4f2682 100644 --- a/packages/core/src/layout/table-terminal-border-plan.ts +++ b/packages/core/src/layout/table-terminal-border-plan.ts @@ -4,6 +4,7 @@ import { lastRowContentDeps } from './table-fragment-content-insets.ts'; import { stripAnchorSinksForProbe } from './table-probe-deps.ts'; import { readOnlyBreakCache } from './paragraph-cache-peek.ts'; import { layoutRowFragment, type TableFlowDeps } from './semantic-table-layout.ts'; +import { probeRowFragmentProgress } from './table-row-progress-probe.ts'; import { MAX_TABLE_COLUMNS, type SemanticTableRow, @@ -25,6 +26,63 @@ export function prepareTerminalBorderPlan( bottom: number, deps: TableFlowDeps ): TerminalBorderPlan | undefined { + const measured = measureTerminalRow(structure, row, left, top, bottom, deps); + return measured && !measured.overflows ? measured.plan : undefined; +} + +/** Where a row is being placed: its table, the row, the table's left edge, its top, the bottom. */ +export interface TerminalRowAt { + readonly structure: SemanticTableStructure; + readonly row: SemanticTableRow; + readonly left: number; + readonly top: number; + readonly bottom: number; + readonly deps: TableFlowDeps; +} + +/** {@link prepareTerminalBorderPlan} for a placement held as one value. */ +export function terminalBorderPlanAt(at: TerminalRowAt): TerminalBorderPlan | undefined { + return prepareTerminalBorderPlan(at.structure, at.row, at.left, at.top, at.bottom, at.deps); +} + +/** + * Room below a fitting row in which its closing border is checked: as wide as the widest + * compound border (thick-thin-thick rules reach about 18pt). With more room the border fits. + */ +const CLOSING_EDGE_BAND_PT = 18; + +/** + * The extra room a row that fits at `naturalHeight` needs because it ends its page: a row that + * ends the page closes it with its own bottom border, which must fit above the bottom too. + * 0 when that border fits, when the row keeps the edge it already has, or when `next` places + * at least its opening below it, so the row does not end the page. + */ +export function closingEdgePt( + at: TerminalRowAt, + naturalHeight: number, + next: SemanticTableRow | undefined, + nextDeps: TableFlowDeps, + heightOf: (row: SemanticTableRow, top: number) => number +): number { + const room = at.bottom - at.top - naturalHeight; + if (room < -0.001 || room > CLOSING_EDGE_BAND_PT) return 0; + const measured = measureTerminalRow(at.structure, at.row, at.left, at.top, at.bottom, at.deps); + if (!measured?.overflows) return 0; + const below = at.top + naturalHeight; + const follows = rowFollowsBelow(next, at.structure, at.left, below, at.bottom, nextDeps, (row) => + heightOf(row, below) + ); + return follows ? 0 : Math.max(0, measured.plan.height - naturalHeight); +} + +function measureTerminalRow( + structure: SemanticTableStructure, + row: SemanticTableRow, + left: number, + top: number, + bottom: number, + deps: TableFlowDeps +): { readonly plan: TerminalBorderPlan; readonly overflows: boolean } | undefined { if ( structure.cellSpacingPt > 0 || structure.float || @@ -48,8 +106,10 @@ export function prepareTerminalBorderPlan( nextLineId: () => `probe-terminal-border-${line++}`, }; const placed = layoutRowFragment(row, structure.columnWidthsPt, left, top, false, 0, probeDeps); - if (placed.remainder !== null || placed.bottom > bottom + 0.001) return undefined; - return { height: placed.record.box.height, deps: terminalDeps, probe: placed.record }; + return { + plan: { height: placed.record.box.height, deps: terminalDeps, probe: placed.record }, + overflows: placed.remainder !== null || placed.bottom > bottom + 0.001, + }; } /** Changing vertical clearance must not silently add or remove already committed content. */ @@ -76,3 +136,36 @@ export function sameTerminalContent(a: TableRowFragmentRecord, b: TableRowFragme ); }); } + +/** + * Whether `next` places at least its opening below `top`, before `bottom`: whole, or, for a + * row that may split, its first line in every cell. Then the row above does not end the page. + * Keeps inside the next row's cells (`w:keepLines`) are not read here; such a row may still + * move whole, and the row above then keeps its ordinary placement. + */ +export function rowFollowsBelow( + next: SemanticTableRow | undefined, + structure: SemanticTableStructure, + left: number, + top: number, + bottom: number, + deps: TableFlowDeps, + heightOf: (row: SemanticTableRow) => number +): boolean { + if (!next) return false; + if (heightOf(next) <= bottom - top + 0.001) return true; + if (next.cantSplit || next.height.rule === 'exact') return false; + return probeRowFragmentProgress( + next, + structure.columnWidthsPt, + left, + top, + bottom, + false, + 0, + deps, + [], + structure.cellSpacingPt, + { requireEveryCell: true } + ); +} diff --git a/packages/docx-to-pdf/docs/api.md b/packages/docx-to-pdf/docs/api.md index 2749cb38a2..62beafb0f4 100644 --- a/packages/docx-to-pdf/docs/api.md +++ b/packages/docx-to-pdf/docs/api.md @@ -122,6 +122,6 @@ Here, `docxBytes` contains the input and `savePdf` is your storage function. Cho ## Resource boundaries -The writer limits operations to `2000000`, diagnostics to `10000`, and uncompressed content to `67108864` bytes. Core also bounds DOCX parsing, fonts, images, and layout. `maxPages` and `maxOutputBytes` do not impose a process memory limit. They check completed layout and completed encoding, respectively. +The writer limits diagnostics to `10000`. It limits paint operations to `2000000` or `6000` for each laid-out page, whichever is greater, up to `40000000`. It limits uncompressed content to `67108864` bytes or `196608` bytes for each page, whichever is greater, up to `536870912` bytes. Text shaping accepts font faces up to `41943040` bytes (40 MiB) unless you set `fontShaping`, so large installed CJK font collections are used rather than replaced. Core also bounds DOCX parsing, fonts, images, and layout. `maxPages` and `maxOutputBytes` do not impose a process memory limit. They check completed layout and completed encoding, respectively. Cancellation checks occur between batches. Synchronous parsing, font work, and image work cannot stop during a call. For hard deadlines, terminate a worker after your deadline. For memory isolation, configure the worker's `resourceLimits.maxOldGenerationSizeMb`. Bound concurrent conversions; each active document retains layout and font data. diff --git a/packages/docx-to-pdf/scripts/evaluation/quick_text.py b/packages/docx-to-pdf/scripts/evaluation/quick_text.py index f23aa61862..79122b08da 100644 --- a/packages/docx-to-pdf/scripts/evaluation/quick_text.py +++ b/packages/docx-to-pdf/scripts/evaluation/quick_text.py @@ -12,8 +12,8 @@ INDEX_VERSION = "page-words-nfc-v1" COMPARE_VERSION = "page-word-multiset-v1" -MAX_PAGES = 1000 -MAX_WORDS = 250000 +MAX_PAGES = 2000 +MAX_WORDS = 1_000_000 MAX_BYTES = 64 * 1024 * 1024 diff --git a/packages/docx-to-pdf/scripts/evaluation/worker.py b/packages/docx-to-pdf/scripts/evaluation/worker.py index 82042beabf..da33cfae72 100755 --- a/packages/docx-to-pdf/scripts/evaluation/worker.py +++ b/packages/docx-to-pdf/scripts/evaluation/worker.py @@ -16,8 +16,8 @@ from PIL import Image, ImageChops, ImageOps PROTOCOL = 1 -MAX_PAGES = 1000 -MAX_WORDS = 250_000 +MAX_PAGES = 2000 +MAX_WORDS = 1_000_000 MAX_PIXELS = 16_000_000 RGB_SIZE = (144, 192) diff --git a/packages/docx-to-pdf/src/context.ts b/packages/docx-to-pdf/src/context.ts index 5a65eb4f74..e811a9e724 100644 --- a/packages/docx-to-pdf/src/context.ts +++ b/packages/docx-to-pdf/src/context.ts @@ -11,6 +11,16 @@ import type { PDFContext, PDFPage, PDFRawStream } from 'pdf-lib'; export const MAX_OUTPUT_BYTES = 64 * 1024 * 1024; export const MAX_OPERATIONS = 2_000_000; +/** + * Paint work grows with the page count, so a long document gets a budget per laid-out + * page on top of the fixed one. Dense text pages use about 3,300 operations and 90 KB of + * uncompressed content; the per-page budgets leave room for twice that. The ceilings + * keep the largest allowed export bounded. + */ +const OPERATIONS_PER_PAGE = 6_000; +const CONTENT_BYTES_PER_PAGE = 192 * 1024; +const MAX_SCALED_OPERATIONS = 40_000_000; +const MAX_SCALED_CONTENT_BYTES = 8 * MAX_OUTPUT_BYTES; export function positiveLimit(value: number, maximum: number, name: string): number { if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) throw new RangeError(`${name} must be an integer from 1 to ${maximum}`); @@ -127,6 +137,8 @@ export class Work { private readonly seen = new Set(); private operations = 0; private contentBytes = 0; + private operationLimit = MAX_OPERATIONS; + private contentLimit = MAX_OUTPUT_BYTES; constructor( readonly signal: AbortSignal, readonly deadline = Infinity @@ -141,13 +153,24 @@ export class Work { cause: this.signal.reason, }); } + /** Size the paint budgets for a layout with `pages` pages. */ + allowPages(pages: number): void { + this.operationLimit = Math.max( + MAX_OPERATIONS, + Math.min(MAX_SCALED_OPERATIONS, pages * OPERATIONS_PER_PAGE) + ); + this.contentLimit = Math.max( + MAX_OUTPUT_BYTES, + Math.min(MAX_SCALED_CONTENT_BYTES, pages * CONTENT_BYTES_PER_PAGE) + ); + } reserveContent(bytes: number): void { this.contentBytes += bytes; - if (this.contentBytes > MAX_OUTPUT_BYTES) throw new PdfWorkLimitError(); + if (this.contentBytes > this.contentLimit) throw new PdfWorkLimitError(); } tick(): void { this.check(); - if (++this.operations > MAX_OPERATIONS) throw new PdfWorkLimitError(); + if (++this.operations > this.operationLimit) throw new PdfWorkLimitError(); } async yield(): Promise { this.check(); diff --git a/packages/docx-to-pdf/src/export-pdf.ts b/packages/docx-to-pdf/src/export-pdf.ts index 2764f1df2c..7b2996e533 100644 --- a/packages/docx-to-pdf/src/export-pdf.ts +++ b/packages/docx-to-pdf/src/export-pdf.ts @@ -101,6 +101,7 @@ async function renderSession( ); const layoutMs = phase() - openMs; if (layout.pages.length > maxPages) throw new PdfPageLimitError(maxPages, layout.pages.length); + work.allowPages(layout.pages.length); reportFontDiagnostics(session.fontResolution, work); const doc = await PDFDocument.create({ updateMetadata: false }); doc.setProducer('docx-editor.dev'); diff --git a/packages/docx-to-pdf/src/font-provisioning.ts b/packages/docx-to-pdf/src/font-provisioning.ts index cb4f13ec3a..7640efcb0d 100644 --- a/packages/docx-to-pdf/src/font-provisioning.ts +++ b/packages/docx-to-pdf/src/font-provisioning.ts @@ -586,6 +586,12 @@ export function installedWordFontResolver(roots: readonly string[]) { ['Aptos-Narrow-Bold-Italic.ttf'], ], Helvetica: [['Helvetica.ttc'], ['Helvetica.ttc'], ['Helvetica.ttc'], ['Helvetica.ttc']], + Garamond: [ + ['GARA.ttf', 'GARA.TTF'], + ['GARABD.ttf', 'GARABD.TTF'], + ['GARAIT.ttf', 'GARAIT.TTF'], + ['GARABDIT.ttf', 'GARABDIT.TTF'], + ], SimHei: [['SimHei.ttf', 'simhei.ttf'], [], [], []], FangSong: [['Fangsong.ttf', 'simfang.ttf'], [], [], []], KaiTi: [['Kaiti.ttf', 'simkai.ttf'], [], [], []], diff --git a/packages/docx-to-pdf/src/open-session.ts b/packages/docx-to-pdf/src/open-session.ts index 77ab4566e9..9f2fdeb612 100644 --- a/packages/docx-to-pdf/src/open-session.ts +++ b/packages/docx-to-pdf/src/open-session.ts @@ -28,6 +28,19 @@ const bundledFonts = packagedFonts({ }), }); +/** Per-face shaping ceiling for PDF export, below the engine's 64 MiB hard maximum. */ +export const PDF_MAX_SHAPING_FONT_BYTES = 40 * 1024 * 1024; + +/** The caller's shaping limits, with the PDF export default for any limit left out. */ +export function exportFontShaping( + options: Pick +): NonNullable { + return { + ...options.fontShaping, + maxFontBytes: options.fontShaping?.maxFontBytes ?? PDF_MAX_SHAPING_FONT_BYTES, + }; +} + /** The export options that shape the session, without the ones that shape the PDF. */ export type SessionOptions = Omit< PdfExportOptions, @@ -58,6 +71,9 @@ export function openExportSession( documentLigatures: options.documentLigatures ?? true, signal, displayMode: options.displayMode ?? 'proposed', + // Installed CJK faces such as SimSun and PMingLiU ship as 17 to 37 MB collections; the + // editor's 16 MiB default would drop them for a substitute with other metrics. + fontShaping: exportFontShaping(options), reuseAcrossRevisions: false, // Every packaged fallback face is on offer. They are read lazily, only when a family the // document uses needs one, so offering the whole list costs a plain Latin document nothing. diff --git a/packages/docx-to-pdf/src/types.ts b/packages/docx-to-pdf/src/types.ts index 83b4e6764a..88110f3745 100644 --- a/packages/docx-to-pdf/src/types.ts +++ b/packages/docx-to-pdf/src/types.ts @@ -9,7 +9,7 @@ import type { OpenFontBackedDocumentForExportResult, OpenFontBackedDocumentForExportOptions, } from '@docx-editor.dev/core/export'; -import type { FontOrigin } from '@docx-editor.dev/core/editor'; +import type { FontOrigin, FontShapingOptions } from '@docx-editor.dev/core/editor'; import type { HeadlessDocumentRejection } from '@docx-editor.dev/core/store'; import type { ReviewDisplayMode } from '@docx-editor.dev/core/editor'; @@ -37,8 +37,13 @@ export type PdfFontsSource = PdfFontOrigin | readonly PdfFontOrigin[]; /** Node conversion controls. Font substitution follows Core's independent fontPolicy. @public */ export interface PdfExportOptions extends Omit< OpenFontBackedDocumentForExportOptions, - 'fonts' | 'measurer' | 'producer' | 'reuseAcrossRevisions' + 'fonts' | 'measurer' | 'producer' | 'reuseAcrossRevisions' | 'fontShaping' > { + /** + * Text shaping limits. The per-face byte ceiling defaults to 40 MiB, so installed CJK + * collections such as SimSun are not replaced. The hard maximum is 64 MiB. + */ + readonly fontShaping?: FontShapingOptions; /** Revision display mode. Defaults to proposed. */ readonly displayMode?: ReviewDisplayMode; /** Apply optional document ligatures during measurement and output. Defaults to true. */ diff --git a/packages/docx-to-pdf/test/export-font-shaping.test.ts b/packages/docx-to-pdf/test/export-font-shaping.test.ts new file mode 100644 index 0000000000..abafeafb3a --- /dev/null +++ b/packages/docx-to-pdf/test/export-font-shaping.test.ts @@ -0,0 +1,25 @@ +/* +Copyright (c) 2026 EigenPal, Inc. All rights reserved. +Licensed under the EigenPal Pro Evaluation License 1.0 — see packages/docx-to-pdf/LICENSE.md. +Production use requires a commercial agreement: licensing@eigenpal.com +*/ +import { expect, test } from 'bun:test'; +import { exportFontShaping, PDF_MAX_SHAPING_FONT_BYTES } from '../src/open-session.ts'; + +test('PDF export shapes installed faces larger than the editor default', () => { + // SimSun ships as an 18 MB collection, above the 16 MiB editor default. + expect(exportFontShaping({})).toEqual({ maxFontBytes: PDF_MAX_SHAPING_FONT_BYTES }); + expect(PDF_MAX_SHAPING_FONT_BYTES).toBeGreaterThan(18_008_680); +}); + +test('an explicit shaping limit wins', () => { + expect(exportFontShaping({ fontShaping: { maxFontBytes: 1024 } })).toEqual({ + maxFontBytes: 1024, + }); +}); + +test('an empty shaping object keeps the PDF export default', () => { + expect(exportFontShaping({ fontShaping: {} })).toEqual({ + maxFontBytes: PDF_MAX_SHAPING_FONT_BYTES, + }); +}); diff --git a/packages/docx-to-pdf/test/export.test.ts b/packages/docx-to-pdf/test/export.test.ts index a37c7583c5..90bc12ae38 100644 --- a/packages/docx-to-pdf/test/export.test.ts +++ b/packages/docx-to-pdf/test/export.test.ts @@ -110,7 +110,8 @@ test('strict fidelity refuses missing glyphs after optional font execution rejec new URL('../../core/src/layout/__tests__/fixtures/fonts/DejaVuSans.ttf', import.meta.url) ) ); - const oversized = new Uint8Array(16 * 1024 * 1024 + 1); + const { PDF_MAX_SHAPING_FONT_BYTES } = await import('../src/open-session.ts'); + const oversized = new Uint8Array(PDF_MAX_SHAPING_FONT_BYTES + 1); oversized.set(data); const family = 'Optional oversized fallback'; const rejected = createFontSource(oversized, { family, weight: 400, style: 'normal' }); diff --git a/packages/docx-to-pdf/test/font-provisioning.test.ts b/packages/docx-to-pdf/test/font-provisioning.test.ts index 0b18ea97a1..54e328a3c1 100644 --- a/packages/docx-to-pdf/test/font-provisioning.test.ts +++ b/packages/docx-to-pdf/test/font-provisioning.test.ts @@ -41,6 +41,27 @@ test('installed discovery includes the default family when no run names it', asy }); }); +test('installed discovery finds Garamond by its short file names', async () => { + await directory(async (root) => { + const face = new URL( + '../../core/src/layout/__tests__/fixtures/fonts/DejaVuSans.ttf', + import.meta.url + ); + for (const name of ['GARA.ttf', 'GARABD.ttf', 'GARAIT.ttf', 'GARABDIT.ttf']) + await copyFile(face, join(root, name)); + const result = await installedWordFontResolver([root])({ + families: ['Garamond'], + defaultFamily: 'Unavailable Font', + }); + expect(result.sources.map((source) => source.request)).toEqual([ + { family: 'Garamond', weight: 400, style: 'normal' }, + { family: 'Garamond', weight: 700, style: 'normal' }, + { family: 'Garamond', weight: 400, style: 'italic' }, + { family: 'Garamond', weight: 700, style: 'italic' }, + ]); + }); +}); + test('a collection without the requested family never substitutes its first face', async () => { await directory(async (root) => { await copyFile( diff --git a/packages/docx-to-pdf/test/work-limits.test.ts b/packages/docx-to-pdf/test/work-limits.test.ts new file mode 100644 index 0000000000..93fb2aea35 --- /dev/null +++ b/packages/docx-to-pdf/test/work-limits.test.ts @@ -0,0 +1,42 @@ +/* +Copyright (c) 2026 EigenPal, Inc. All rights reserved. +Licensed under the EigenPal Pro Evaluation License 1.0 — see packages/docx-to-pdf/LICENSE.md. +Production use requires a commercial agreement: licensing@eigenpal.com +*/ +import { describe, expect, test } from 'bun:test'; +import { MAX_OPERATIONS, MAX_OUTPUT_BYTES, PdfWorkLimitError, Work } from '../src/context.ts'; + +function ticksUntilLimit(work: Work, cap: number): number { + for (let count = 1; count <= cap; count += 1) { + try { + work.tick(); + } catch (error) { + expect(error).toBeInstanceOf(PdfWorkLimitError); + return count; + } + } + return Infinity; +} + +describe('PDF work budgets', () => { + test('a short document keeps the fixed budgets', () => { + const work = new Work(new AbortController().signal); + work.allowPages(25); + expect(ticksUntilLimit(work, MAX_OPERATIONS + 1)).toBe(MAX_OPERATIONS + 1); + expect(() => work.reserveContent(MAX_OUTPUT_BYTES + 1)).toThrow(PdfWorkLimitError); + }); + + test('a long document gets a budget for each page', () => { + const work = new Work(new AbortController().signal); + work.allowPages(1_400); + for (let count = 0; count < MAX_OPERATIONS + 1_000; count += 1) work.tick(); + work.reserveContent(MAX_OUTPUT_BYTES * 2); + expect(ticksUntilLimit(work, 10_000_000)).toBe(1_400 * 6_000 - MAX_OPERATIONS - 1_000 + 1); + }); + + test('the per-page budgets stop at a ceiling', () => { + const work = new Work(new AbortController().signal); + work.allowPages(1_000_000); + expect(() => work.reserveContent(8 * MAX_OUTPUT_BYTES + 1)).toThrow(PdfWorkLimitError); + }); +});