From e21ca69fe7f79acf6397f747f6fe6dc6005c53c8 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 22:26:59 +0200 Subject: [PATCH 01/25] fix(core): weigh space compression against expansion in justified lines Co-Authored-By: Claude Opus 5.5 --- packages/core/src/layout/paragraph-space-shrink.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/layout/paragraph-space-shrink.ts b/packages/core/src/layout/paragraph-space-shrink.ts index a2040b407..316c53d4f 100644 --- a/packages/core/src/layout/paragraph-space-shrink.ts +++ b/packages/core/src/layout/paragraph-space-shrink.ts @@ -101,8 +101,10 @@ export function fitsWithSpaceShrink( 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; } /** From f3316fbbe7296bf743b6dfc4d1cb90b6f6c66b9f Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 22:28:46 +0200 Subject: [PATCH 02/25] fix(core): let a hyphen-break fragment borrow space in justified lines Co-Authored-By: Claude Opus 5.5 --- packages/core/src/layout/paragraph-flow.ts | 3 ++- packages/core/src/layout/paragraph-space-shrink.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/layout/paragraph-flow.ts b/packages/core/src/layout/paragraph-flow.ts index 8a0374bc8..bcd0deab9 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, @@ -1474,7 +1475,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 316c53d4f..176ff50f0 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 ("anti-" of + * "anti-corruption"). 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. From c78c54550242d45473f300785cfd5119403cc16d Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 22:30:12 +0200 Subject: [PATCH 03/25] fix(core): let currency and math symbol words lend justified space Co-Authored-By: Claude Opus 5.5 --- packages/core/src/layout/paragraph-space-shrink.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/layout/paragraph-space-shrink.ts b/packages/core/src/layout/paragraph-space-shrink.ts index 176ff50f0..58b6a1c84 100644 --- a/packages/core/src/layout/paragraph-space-shrink.ts +++ b/packages/core/src/layout/paragraph-space-shrink.ts @@ -39,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 From 0f647c0766562dead3a916bc4b9f0a36a16dfa7a Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 22:35:50 +0200 Subject: [PATCH 04/25] fix(docx-to-pdf): scale paint work budgets with the page count Long documents within the documented page limit no longer fail with workLimitExceeded. The evaluation scripts accept up to 2000 pages. Co-Authored-By: Claude Opus 5.5 --- packages/docx-to-pdf/docs/api.md | 2 +- .../scripts/evaluation/quick_text.py | 4 +- .../docx-to-pdf/scripts/evaluation/worker.py | 4 +- packages/docx-to-pdf/src/context.ts | 27 +++++++++++- packages/docx-to-pdf/src/export-pdf.ts | 1 + packages/docx-to-pdf/test/work-limits.test.ts | 42 +++++++++++++++++++ 6 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 packages/docx-to-pdf/test/work-limits.test.ts diff --git a/packages/docx-to-pdf/docs/api.md b/packages/docx-to-pdf/docs/api.md index 2749cb38a..16c583b68 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 6,000 for each laid-out page, whichever is greater, up to `40000000`. It limits uncompressed content to `67108864` bytes or 196,608 bytes for each page, whichever is greater, up to `536870912` 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. 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 f23aa6186..79122b08d 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 82042beab..da33cfae7 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 5a65eb4f7..e811a9e72 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 2764f1df2..7b2996e53 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/test/work-limits.test.ts b/packages/docx-to-pdf/test/work-limits.test.ts new file mode 100644 index 000000000..93fb2aea3 --- /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); + }); +}); From abcd77874c5972866b09c0fcda163127b7478bd4 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 22:41:33 +0200 Subject: [PATCH 05/25] fix(core): a note reference on a paragraph's last line needs its space after to fit Co-Authored-By: Claude Opus 5.5 --- packages/core/src/layout/note-fragment-geometry.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/layout/note-fragment-geometry.ts b/packages/core/src/layout/note-fragment-geometry.ts index 7a1b0e512..f77f84295 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; From d7081f90df05f1f37cd78f6469b84d3b736feebf Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 22:55:56 +0200 Subject: [PATCH 06/25] fix(core): fit a paragraph's space after above a footnote area Co-Authored-By: Claude Opus 5.5 --- packages/core/src/layout/note-area-fit.ts | 51 +++++++++++++++++++ .../core/src/layout/note-reserve-holdout.ts | 4 +- packages/core/src/layout/semantic-layout.ts | 9 +++- 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/layout/note-area-fit.ts 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 000000000..fa425f284 --- /dev/null +++ b/packages/core/src/layout/note-area-fit.ts @@ -0,0 +1,51 @@ +// 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'; + +function citesFootnote(line: LineRecord): 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 LineRecord[], + line: LineRecord +): boolean { + if (citesFootnote(line) || pending.some(citesFootnote)) return true; + return fragments.some( + (fragment) => fragment.kind === 'paragraph' && fragment.lines.some(citesFootnote) + ); +} + +/** + * 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; + const cites = page.fragments.some( + (fragment) => fragment.kind === 'paragraph' && fragment.lines.some(citesFootnote) + ); + return cites ? last.spacing.after : 0; +} diff --git a/packages/core/src/layout/note-reserve-holdout.ts b/packages/core/src/layout/note-reserve-holdout.ts index 33e9f9de8..34daf2d75 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 diff --git a/packages/core/src/layout/semantic-layout.ts b/packages/core/src/layout/semantic-layout.ts index cce41c648..e0bc4bc9f 100644 --- a/packages/core/src/layout/semantic-layout.ts +++ b/packages/core/src/layout/semantic-layout.ts @@ -192,6 +192,7 @@ import { layoutSemanticDocumentWithNotes, notesReserveContextKey, } from './note-pagination.ts'; +import { pageHoldsFootnoteReference } from './note-area-fit.ts'; import { passProducerOf } from './pass-producer.ts'; import { documentProjectionProducer } from './document-property-context.ts'; @@ -2503,8 +2504,12 @@ 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 noteArea = + isLastLine && + (pageBottomReserves?.get(pageIndexStart + pages.length) ?? 0) > 0 && + pageHoldsFootnoteReference(pageFragments, pending, pendingLine); + const tail = isLastLine ? borderExtent + (noteArea ? spacing.after : 0) : 0; if (lineIndex === fragmentFirstLine) { fragmentParagraphStartY = cursorY; if (paragraphHasAnchors && paragraphAnchorOrigin === null && fragmentIndex === 0) { From 29d3be3738f9c42eca9aca81a1b2edb060b4e288 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 22:57:54 +0200 Subject: [PATCH 07/25] test(core): cover justified space shrink and footnote space-after fit Co-Authored-By: Claude Opus 5.5 --- .../footnote-space-after-fit.test.ts | 101 ++++++++++++++++++ .../__tests__/paragraph-space-shrink.test.ts | 22 ++++ 2 files changed, 123 insertions(+) create mode 100644 packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts 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 000000000..ac0dcd4e9 --- /dev/null +++ b/packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts @@ -0,0 +1,101 @@ +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'; + +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 layout(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); + return layoutSemanticDocument(part, 1, { + measurer, + session: createLayoutSession(), + notes: { + footnotesPart: resolveNotesPart(loaded.package, 'footnote'), + endnotesPart: null, + documentFootnoteProps: fn, + footnotePropsBySection: [fn], + documentEndnoteProps: en, + endnotePropsBySection: [en], + measurer, + producer: 'footnote-space-after-fit-test', + }, + }); +} + +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); + }); +}); 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 703a3581a..cb1e53062 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,25 @@ 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'); +}); From 9435b4f0b7e0b4eb5f72c53d26ec412999deed4b Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:03:26 +0200 Subject: [PATCH 08/25] fix(docx-to-pdf): find installed Garamond faces Co-Authored-By: Claude Opus 5.5 --- packages/docx-to-pdf/src/font-provisioning.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/docx-to-pdf/src/font-provisioning.ts b/packages/docx-to-pdf/src/font-provisioning.ts index cb4f13ec3..7640efcb0 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'], [], [], []], From bc509b59b22fdf8f8c28a89507727f161c60c447 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:04:58 +0200 Subject: [PATCH 09/25] fix(core): read note citations from pending lines by their spans Co-Authored-By: Claude Opus 5.5 --- packages/core/src/layout/note-area-fit.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/layout/note-area-fit.ts b/packages/core/src/layout/note-area-fit.ts index fa425f284..2bac395cc 100644 --- a/packages/core/src/layout/note-area-fit.ts +++ b/packages/core/src/layout/note-area-fit.ts @@ -2,7 +2,9 @@ import { fragmentCursorBottomPt } from './note-fragment-geometry.ts'; import type { BlockFragmentRecord, LineRecord, PageRecord } from './semantic-records.ts'; -function citesFootnote(line: LineRecord): boolean { +type SpannedLine = Pick; + +function citesFootnote(line: SpannedLine): boolean { return line.spans.some( (span) => span.noteNav?.direction === 'to-note' && span.noteNav.scopeId.startsWith('footnote:') ); @@ -19,8 +21,8 @@ function citesFootnote(line: LineRecord): boolean { */ export function pageHoldsFootnoteReference( fragments: readonly BlockFragmentRecord[], - pending: readonly LineRecord[], - line: LineRecord + pending: readonly SpannedLine[], + line: SpannedLine ): boolean { if (citesFootnote(line) || pending.some(citesFootnote)) return true; return fragments.some( From 09bf80200d9308d25736a275886a6e4136007e71 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:05:24 +0200 Subject: [PATCH 10/25] test(docx-to-pdf): cover Garamond discovery; add changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/justified-lines-footnote-fit.md | 6 ++++++ .../test/font-provisioning.test.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .changeset/justified-lines-footnote-fit.md diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md new file mode 100644 index 000000000..314237290 --- /dev/null +++ b/.changeset/justified-lines-footnote-fit.md @@ -0,0 +1,6 @@ +--- +'@docx-editor.dev/core': patch +'@docx-editor.dev/docx-to-pdf': patch +--- + +Justified lines now compress spaces in more cases, including after hyphens and currency amounts, and a paragraph's space after now fits above the footnote area on its page. PDF export now completes long documents within the page limit and finds installed Garamond fonts. diff --git a/packages/docx-to-pdf/test/font-provisioning.test.ts b/packages/docx-to-pdf/test/font-provisioning.test.ts index 0b18ea97a..54e328a3c 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( From bb32385218b3c9ad3efb253e0a2a9ebcc1d9040e Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:21:22 +0200 Subject: [PATCH 11/25] fix(core): a table row that ends the page needs room for its closing border Co-Authored-By: Claude Opus 5.5 --- .../table-closing-border-fit.test.ts | 59 +++++++++++++++++++ .../table-width-moved-row-compare.test.ts | 5 +- .../core/src/layout/table-flow-pagination.ts | 35 +++++------ .../src/layout/table-terminal-border-plan.ts | 44 +++++++++++++- 4 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/layout/__tests__/table-closing-border-fit.test.ts 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 000000000..3a8959145 --- /dev/null +++ b/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts @@ -0,0 +1,59 @@ +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() { + const parsed = readOoxmlPart( + ` + ${edges.map((edge) => ``).join('')} + ${['top', 'bottom', 'left', 'right'].map((s) => ``).join('')} + + ${['One', 'Two', 'Three'].map((text) => `${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): string[] { + const result = layoutSemanticDocument(table(), 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', 'Three']); +}); 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 4267effec..4d7f71ed0 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,10 @@ 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); }); test('mixed cell rules override the table rules on some rows', () => { diff --git a/packages/core/src/layout/table-flow-pagination.ts b/packages/core/src/layout/table-flow-pagination.ts index 33fb3cf95..d50993f87 100644 --- a/packages/core/src/layout/table-flow-pagination.ts +++ b/packages/core/src/layout/table-flow-pagination.ts @@ -29,7 +29,10 @@ 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 { + closingEdgeOverflowHeight, + prepareTerminalBorderPlan, +} 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'; @@ -642,22 +645,20 @@ export function* paginateTableInFlowSteps( if (rows.length === 0 && clearance.clear(clearance.pending)) admitSpans(bodyRowIndex, row); } + const terminalAt = () => + [structure, row, tableLeft, flow.cursorY, contentHeight(), rowDeps()] as const; + // 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, below] = [bodyRows[bodyRowIndex + 1], flow.cursorY + naturalHeight]; + const fits = (room: number) => !!next && rowHeightOf(next, below) <= room; + const closed = closingEdgeOverflowHeight(...terminalAt(), naturalHeight, fits); + closingEdge = closed === undefined ? 0 : closed - naturalHeight; return; - const plan = prepareTerminalBorderPlan( - structure, - row, - tableLeft, - flow.cursorY, - contentHeight(), - rowDeps() - ); + } + const plan = prepareTerminalBorderPlan(...terminalAt()); if (!plan) return; terminalDeps = plan.deps; naturalHeight = plan.height; @@ -743,7 +744,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 +753,7 @@ export function* paginateTableInFlowSteps( structure.columnWidthsPt, tableLeft, flow.cursorY, - contentHeight(), + contentHeight() - closingEdge, false, 0, rowDeps(), diff --git a/packages/core/src/layout/table-terminal-border-plan.ts b/packages/core/src/layout/table-terminal-border-plan.ts index 20c4fea46..37deb5272 100644 --- a/packages/core/src/layout/table-terminal-border-plan.ts +++ b/packages/core/src/layout/table-terminal-border-plan.ts @@ -25,6 +25,44 @@ 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; +} + +/** Room below a fitting row in which its closing border is checked: compound rules included. */ +const CLOSING_EDGE_BAND_PT = 18; + +/** + * The height a row that fits at `naturalHeight` must count instead: a row that ends its page + * closes it with its own bottom border, which must fit above `bottom` too. Undefined when + * the row fits that way, keeps the edge it already has, or a following row fits below it + * (`nextFits`), so it does not end the page. + */ +export function closingEdgeOverflowHeight( + structure: SemanticTableStructure, + row: SemanticTableRow, + left: number, + top: number, + bottom: number, + deps: TableFlowDeps, + naturalHeight: number, + nextFits: (room: number) => boolean +): number | undefined { + const room = bottom - top - naturalHeight; + if (room < -0.001 || room > CLOSING_EDGE_BAND_PT) return undefined; + const measured = measureTerminalRow(structure, row, left, top, bottom, deps); + if (!measured?.overflows || nextFits(room)) return undefined; + return measured.plan.height; +} + +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 +86,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. */ From 7690f2b22c071605913e1b18c51023e498f84ace Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:23:19 +0200 Subject: [PATCH 12/25] refactor(core): pass a table row placement as one value Co-Authored-By: Claude Opus 5.5 --- .../core/src/layout/table-flow-pagination.ts | 9 ++++---- .../src/layout/table-terminal-border-plan.ts | 23 ++++++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/core/src/layout/table-flow-pagination.ts b/packages/core/src/layout/table-flow-pagination.ts index d50993f87..c59bccb6d 100644 --- a/packages/core/src/layout/table-flow-pagination.ts +++ b/packages/core/src/layout/table-flow-pagination.ts @@ -31,7 +31,8 @@ import { closingCellSpacingPt } from './table-cell-spacing.ts'; import { vMergePlanFor } from './table-vmerge-plan.ts'; import { closingEdgeOverflowHeight, - prepareTerminalBorderPlan, + 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'; @@ -645,7 +646,7 @@ export function* paginateTableInFlowSteps( if (rows.length === 0 && clearance.clear(clearance.pending)) admitSpans(bodyRowIndex, row); } - const terminalAt = () => + const terminalAt = (): TerminalRowAt => [structure, row, tableLeft, flow.cursorY, contentHeight(), rowDeps()] as const; // The room a row that ends the page needs below it for its own closing border. let closingEdge = 0; @@ -654,11 +655,11 @@ export function* paginateTableInFlowSteps( if (naturalHeight <= contentHeight() - flow.cursorY + 0.001) { const [next, below] = [bodyRows[bodyRowIndex + 1], flow.cursorY + naturalHeight]; const fits = (room: number) => !!next && rowHeightOf(next, below) <= room; - const closed = closingEdgeOverflowHeight(...terminalAt(), naturalHeight, fits); + const closed = closingEdgeOverflowHeight(terminalAt(), naturalHeight, fits); closingEdge = closed === undefined ? 0 : closed - naturalHeight; return; } - const plan = prepareTerminalBorderPlan(...terminalAt()); + const plan = terminalBorderPlanAt(terminalAt()); if (!plan) return; terminalDeps = plan.deps; naturalHeight = plan.height; diff --git a/packages/core/src/layout/table-terminal-border-plan.ts b/packages/core/src/layout/table-terminal-border-plan.ts index 37deb5272..34603d180 100644 --- a/packages/core/src/layout/table-terminal-border-plan.ts +++ b/packages/core/src/layout/table-terminal-border-plan.ts @@ -29,6 +29,21 @@ export function prepareTerminalBorderPlan( return measured && !measured.overflows ? measured.plan : undefined; } +/** A row's placement: its table, the row, the table's left edge, its top, the page bottom. */ +export type TerminalRowAt = readonly [ + SemanticTableStructure, + SemanticTableRow, + number, + number, + number, + TableFlowDeps, +]; + +/** {@link prepareTerminalBorderPlan} for a placement held as one value. */ +export function terminalBorderPlanAt(at: TerminalRowAt): TerminalBorderPlan | undefined { + return prepareTerminalBorderPlan(at[0], at[1], at[2], at[3], at[4], at[5]); +} + /** Room below a fitting row in which its closing border is checked: compound rules included. */ const CLOSING_EDGE_BAND_PT = 18; @@ -39,15 +54,11 @@ const CLOSING_EDGE_BAND_PT = 18; * (`nextFits`), so it does not end the page. */ export function closingEdgeOverflowHeight( - structure: SemanticTableStructure, - row: SemanticTableRow, - left: number, - top: number, - bottom: number, - deps: TableFlowDeps, + at: TerminalRowAt, naturalHeight: number, nextFits: (room: number) => boolean ): number | undefined { + const [structure, row, left, top, bottom, deps] = at; const room = bottom - top - naturalHeight; if (room < -0.001 || room > CLOSING_EDGE_BAND_PT) return undefined; const measured = measureTerminalRow(structure, row, left, top, bottom, deps); From 6d4d8460ddd33189c2b0f198b119c5c0bb090270 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:35:35 +0200 Subject: [PATCH 13/25] chore: describe table border fit in the changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/justified-lines-footnote-fit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md index 314237290..3500f1656 100644 --- a/.changeset/justified-lines-footnote-fit.md +++ b/.changeset/justified-lines-footnote-fit.md @@ -3,4 +3,4 @@ '@docx-editor.dev/docx-to-pdf': patch --- -Justified lines now compress spaces in more cases, including after hyphens and currency amounts, and a paragraph's space after now fits above the footnote area on its page. PDF export now completes long documents within the page limit and finds installed Garamond fonts. +Justified lines now compress spaces in more cases, a paragraph's space after now fits above the footnote area on its page, and a table row that ends a page now leaves room for its bottom border. PDF export now completes long documents within the page limit and finds installed Garamond fonts. From 05fc6c74b461d79c5e7f3df49fda42f4094027a4 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:45:52 +0200 Subject: [PATCH 14/25] fix(core): keep an automatic-width table's outer rules inside the text column Co-Authored-By: Claude Opus 5.5 --- .../table-auto-width-outer-box.test.ts | 48 +++++++++++++++++++ .../layout/__tests__/table-row-facts.test.ts | 2 +- .../__tests__/table-width-side-rules.test.ts | 2 +- .../src/layout/legacy-table-side-rules.ts | 27 ++++++++++- packages/core/src/layout/semantic-table.ts | 9 ++++ .../core/src/layout/table-autofit-widths.ts | 34 +++++++++---- 6 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/layout/__tests__/table-auto-width-outer-box.test.ts 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 000000000..a73205dd9 --- /dev/null +++ b/packages/core/src/layout/__tests__/table-auto-width-outer-box.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 edges = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']; + +/** An automatic-width table with 3pt rules whose grid fills the 468pt text column. */ +function grid(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); + const layout = layoutSemanticDocument(parsed.part, 1, { + measurer: createFixedMeasurer(6, 14), + compatibilityMode: 15, + }); + 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); + }); +} 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 b7e85c847..1c5b5d796 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-side-rules.test.ts b/packages/core/src/layout/__tests__/table-width-side-rules.test.ts index cb46dbcd7..6202d9b9c 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/legacy-table-side-rules.ts b/packages/core/src/layout/legacy-table-side-rules.ts index 1674c03b8..7790119f8 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/semantic-table.ts b/packages/core/src/layout/semantic-table.ts index a3356264e..dcbf2e538 100644 --- a/packages/core/src/layout/semantic-table.ts +++ b/packages/core/src/layout/semantic-table.ts @@ -6,6 +6,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 +307,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; /** @@ -900,6 +906,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 +921,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/table-autofit-widths.ts b/packages/core/src/layout/table-autofit-widths.ts index a583364c1..c5825543f 100644 --- a/packages/core/src/layout/table-autofit-widths.ts +++ b/packages/core/src/layout/table-autofit-widths.ts @@ -672,7 +672,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 +739,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; @@ -853,6 +863,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 +876,18 @@ 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; + const widths = spread + ? sameOrComputed(structure, spread) + : structure.autoOuterRulePt + ? sameOrComputed( + structure, + widenAutofitColumns(cellWidths, minimumsNow, availableFor(roomWidthPt), Infinity, true) + ) + : structure.columnWidthsPt; byStructure.set(structure, { contentWidthPt, widths }); return widths; } @@ -876,9 +896,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); From 80562e43d2357a3ee29555425cb0c1991bc1f53c Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Wed, 14 Oct 2026 23:50:57 +0200 Subject: [PATCH 15/25] fix(core): let every trailing space hang on the shorter justified line Co-Authored-By: Claude Opus 5.5 --- .../layout/__tests__/paragraph-space-shrink.test.ts | 11 +++++++++++ packages/core/src/layout/paragraph-space-shrink.ts | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) 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 cb1e53062..18106e0c6 100644 --- a/packages/core/src/layout/__tests__/paragraph-space-shrink.test.ts +++ b/packages/core/src/layout/__tests__/paragraph-space-shrink.test.ts @@ -335,3 +335,14 @@ 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/paragraph-space-shrink.ts b/packages/core/src/layout/paragraph-space-shrink.ts index 58b6a1c84..290b96c26 100644 --- a/packages/core/src/layout/paragraph-space-shrink.ts +++ b/packages/core/src/layout/paragraph-space-shrink.ts @@ -105,7 +105,12 @@ 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; From 7f1ba4a99c80528c0423f1d9c9d130574bde6f84 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 00:04:23 +0200 Subject: [PATCH 16/25] fix(core): size autofit columns for deleted content in every view Co-Authored-By: Claude Opus 5.5 --- .../table-autofit-deleted-content.test.ts | 48 +++++++++++++++++++ packages/core/src/layout/semantic-table.ts | 16 +++++++ .../core/src/layout/table-autofit-widths.ts | 12 ++++- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/layout/__tests__/table-autofit-deleted-content.test.ts 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 000000000..284c8b29e --- /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/semantic-table.ts b/packages/core/src/layout/semantic-table.ts index dcbf2e538..a0bd5853a 100644 --- a/packages/core/src/layout/semantic-table.ts +++ b/packages/core/src/layout/semantic-table.ts @@ -411,12 +411,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' + ? 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, diff --git a/packages/core/src/layout/table-autofit-widths.ts b/packages/core/src/layout/table-autofit-widths.ts index c5825543f..0b12e3f30 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. */ @@ -845,7 +850,12 @@ export function autofitColumnWidthsPt( // the geometry widening can bring. const ifWidened: number[] = []; const content = emptyColumnContent(); - const minimumsNow = autofitColumnMinimumsPt(structure, context, view, ifWidened, content); + const markup = view.markupStructure; + const measured = + markup && markup.columnWidthsPt.length === structure.columnWidthsPt.length ? markup : structure; + const measureView: AutofitView = + measured === structure ? view : { ...view, displayMode: 'all-markup' }; + const minimumsNow = autofitColumnMinimumsPt(measured, context, measureView, ifWidened, content); // 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); From e8a4b95f61e7f9fd6ed1ba56840b00fee8c8a5c1 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 00:04:23 +0200 Subject: [PATCH 17/25] fix(core): let a cross-reference result complete a justified word Co-Authored-By: Claude Opus 5.5 --- .../__tests__/field-button-projection.test.ts | 2 +- .../field-doc-property-projection.test.ts | 2 +- .../__tests__/field-symbol-projection.test.ts | 2 +- .../paragraph-space-shrink-terminal.test.ts | 7 +++-- .../__tests__/space-shrink-piece.test.ts | 31 +++++++++++++++++++ packages/core/src/layout/field-pieces.ts | 5 +++ .../core/src/layout/field-projection-walk.ts | 6 +++- .../core/src/layout/space-shrink-piece.ts | 12 ++++--- 8 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/layout/__tests__/space-shrink-piece.test.ts diff --git a/packages/core/src/layout/__tests__/field-button-projection.test.ts b/packages/core/src/layout/__tests__/field-button-projection.test.ts index 596bbf2d9..2e50dd068 100644 --- a/packages/core/src/layout/__tests__/field-button-projection.test.ts +++ b/packages/core/src/layout/__tests__/field-button-projection.test.ts @@ -75,7 +75,7 @@ describe('a complex MACROBUTTON field', () => { expect(pieces.map((piece) => piece.text)).toEqual(['A', 'Click Here', 'B']); const button = pieces[1]!; expect(button).toMatchObject({ start: 1, end: 2, projected: true }); - expect(button.fieldAtom).toEqual({ formField: false }); + expect(button.fieldAtom).toEqual({ formField: false, fixedResult: true }); expect(pieces[2]).toMatchObject({ start: 2, end: 3 }); }); diff --git a/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts b/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts index 97c6fc7a8..59d6669a8 100644 --- a/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts +++ b/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts @@ -100,7 +100,7 @@ describe('a complex document-property field with no cached result', () => { ); expect(pieces.map((piece) => piece.text)).toEqual(['A', value, 'B']); expect(pieces[1]).toMatchObject({ start: 1, end: 2, projected: true }); - expect(pieces[1]!.fieldAtom).toEqual({ formField: false }); + expect(pieces[1]!.fieldAtom).toEqual({ formField: false, fixedResult: true }); }); } diff --git a/packages/core/src/layout/__tests__/field-symbol-projection.test.ts b/packages/core/src/layout/__tests__/field-symbol-projection.test.ts index 9c669f313..3de632331 100644 --- a/packages/core/src/layout/__tests__/field-symbol-projection.test.ts +++ b/packages/core/src/layout/__tests__/field-symbol-projection.test.ts @@ -90,7 +90,7 @@ describe('a complex SYMBOL field', () => { const glyph = pieces[1]!; expect(glyph).toMatchObject({ start: 1, end: 2, projected: true }); expect(glyph.style.fontFamily).toBe('Wingdings'); - expect(glyph.fieldAtom).toEqual({ formField: false }); + expect(glyph.fieldAtom).toEqual({ formField: false, fixedResult: true }); expect(pieces[2]).toMatchObject({ start: 2, end: 3 }); }); 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 35476eaf7..cd05a3472 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,16 @@ 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`, ]) { 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__/space-shrink-piece.test.ts b/packages/core/src/layout/__tests__/space-shrink-piece.test.ts new file mode 100644 index 000000000..d0f81091e --- /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: '2.2(d)', 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/field-pieces.ts b/packages/core/src/layout/field-pieces.ts index 390d50f97..17189db38 100644 --- a/packages/core/src/layout/field-pieces.ts +++ b/packages/core/src/layout/field-pieces.ts @@ -64,6 +64,11 @@ export interface PositionalTab { * view decision made downstream, so this states the fact and nothing about the appearance. */ export interface FieldAtomMarker { + /** + * The field shows a result whose width pagination never changes (a cross-reference + * number, not a page number), 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 6e25bc492..a0b05d96f 100644 --- a/packages/core/src/layout/field-projection-walk.ts +++ b/packages/core/src/layout/field-projection-walk.ts @@ -267,7 +267,11 @@ export function unmergedPiecesOfParagraphForDisplay( carriedMemo = { ...captured, ...(carriedLink ? { linkOverride: carriedLink } : {}), - fieldAtom: { formField, ...(formControl ? { formControl } : {}) }, + fieldAtom: { + formField, + ...(formControl ? { formControl } : {}), + ...(pending?.kind === null && !formField ? { fixedResult: true as const } : {}), + }, }; return carriedMemo; }; diff --git a/packages/core/src/layout/space-shrink-piece.ts b/packages/core/src/layout/space-shrink-piece.ts index 839886e2a..20cb12a4d 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; } From a034fec08540c4a0d374470111985470630d0f8c Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 00:37:59 +0200 Subject: [PATCH 18/25] fix(core): keep the view's own column minimums beside deleted content Co-Authored-By: Claude Opus 5.5 --- .changeset/justified-lines-footnote-fit.md | 2 +- .../__tests__/widened-cell-insets.test.ts | 2 +- .../core/src/layout/table-autofit-widths.ts | 28 +++++++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md index 3500f1656..eff7dcfae 100644 --- a/.changeset/justified-lines-footnote-fit.md +++ b/.changeset/justified-lines-footnote-fit.md @@ -3,4 +3,4 @@ '@docx-editor.dev/docx-to-pdf': patch --- -Justified lines now compress spaces in more cases, a paragraph's space after now fits above the footnote area on its page, and a table row that ends a page now leaves room for its bottom border. PDF export now completes long documents within the page limit and finds installed Garamond fonts. +Justified lines compress spaces in more cases, paragraphs and table rows above a page end or footnote area keep room for their space after and bottom border, and automatic-width tables keep their outer borders inside the text column and size columns for tracked deleted content. PDF export completes long documents within the page limit and finds installed Garamond fonts. 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 b23ee6819..eac82052e 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/table-autofit-widths.ts b/packages/core/src/layout/table-autofit-widths.ts index 0b12e3f30..e4aa0eb4c 100644 --- a/packages/core/src/layout/table-autofit-widths.ts +++ b/packages/core/src/layout/table-autofit-widths.ts @@ -850,12 +850,30 @@ export function autofitColumnWidthsPt( // the geometry widening can bring. 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; - const measured = - markup && markup.columnWidthsPt.length === structure.columnWidthsPt.length ? markup : structure; - const measureView: AutofitView = - measured === structure ? view : { ...view, displayMode: 'all-markup' }; - const minimumsNow = autofitColumnMinimumsPt(measured, context, measureView, ifWidened, content); + 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); From 853ee18c10768115e34aff7884985428ced073c2 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 08:05:57 +0200 Subject: [PATCH 19/25] chore: use neutral examples in comments and tests Co-Authored-By: Claude Opus 5.5 --- packages/core/src/layout/__tests__/space-shrink-piece.test.ts | 2 +- packages/core/src/layout/paragraph-space-shrink.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/layout/__tests__/space-shrink-piece.test.ts b/packages/core/src/layout/__tests__/space-shrink-piece.test.ts index d0f81091e..d078d20a2 100644 --- a/packages/core/src/layout/__tests__/space-shrink-piece.test.ts +++ b/packages/core/src/layout/__tests__/space-shrink-piece.test.ts @@ -3,7 +3,7 @@ import type { FieldAwarePiece } from '../field-pieces.ts'; import { isSpaceShrinkWordPiece } from '../space-shrink-piece.ts'; const piece = (extra: Partial): FieldAwarePiece => - ({ text: '2.2(d)', props: [], style: {}, start: 10, end: 11, ...extra }) as 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( diff --git a/packages/core/src/layout/paragraph-space-shrink.ts b/packages/core/src/layout/paragraph-space-shrink.ts index 290b96c26..275e6c485 100644 --- a/packages/core/src/layout/paragraph-space-shrink.ts +++ b/packages/core/src/layout/paragraph-space-shrink.ts @@ -20,8 +20,8 @@ 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 ("anti-" of - * "anti-corruption"). That fragment can borrow inter-word space as a whole word does. + * 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); From 92d5e7ed28ced9a05c424447b22c10ef89a2cdc9 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 08:08:02 +0200 Subject: [PATCH 20/25] fix(docx-to-pdf): shape installed CJK font collections up to 40 MiB Co-Authored-By: Claude Opus 5.5 --- packages/docx-to-pdf/docs/api.md | 2 +- packages/docx-to-pdf/src/open-session.ts | 13 +++++++++++++ .../test/export-font-shaping.test.ts | 19 +++++++++++++++++++ packages/docx-to-pdf/test/export.test.ts | 3 ++- 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 packages/docx-to-pdf/test/export-font-shaping.test.ts diff --git a/packages/docx-to-pdf/docs/api.md b/packages/docx-to-pdf/docs/api.md index 16c583b68..deb1050ce 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 diagnostics to `10000`. It limits paint operations to `2000000` or 6,000 for each laid-out page, whichever is greater, up to `40000000`. It limits uncompressed content to `67108864` bytes or 196,608 bytes for each page, whichever is greater, up to `536870912` 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. +Text shaping accepts font faces up to `41943040` bytes (40 MiB) unless you set `fontShaping`, so large installed CJK fonts are not replaced. The writer limits diagnostics to `10000`. It limits paint operations to `2000000` or 6,000 for each laid-out page, whichever is greater, up to `40000000`. It limits uncompressed content to `67108864` bytes or 196,608 bytes for each page, whichever is greater, up to `536870912` 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. 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/src/open-session.ts b/packages/docx-to-pdf/src/open-session.ts index 77ab4566e..32563f865 100644 --- a/packages/docx-to-pdf/src/open-session.ts +++ b/packages/docx-to-pdf/src/open-session.ts @@ -28,6 +28,16 @@ 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, or the PDF export default. */ +export function exportFontShaping( + options: Pick +): NonNullable { + return 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 +68,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/test/export-font-shaping.test.ts b/packages/docx-to-pdf/test/export-font-shaping.test.ts new file mode 100644 index 000000000..6b243b9e1 --- /dev/null +++ b/packages/docx-to-pdf/test/export-font-shaping.test.ts @@ -0,0 +1,19 @@ +/* +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, + }); +}); diff --git a/packages/docx-to-pdf/test/export.test.ts b/packages/docx-to-pdf/test/export.test.ts index a37c7583c..90bc12ae3 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' }); From 0dbb5cb15b7c2aed2e797666e336227859993f23 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 08:08:02 +0200 Subject: [PATCH 21/25] chore: mention CJK fonts in the changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/justified-lines-footnote-fit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md index eff7dcfae..6a0437f07 100644 --- a/.changeset/justified-lines-footnote-fit.md +++ b/.changeset/justified-lines-footnote-fit.md @@ -3,4 +3,4 @@ '@docx-editor.dev/docx-to-pdf': patch --- -Justified lines compress spaces in more cases, paragraphs and table rows above a page end or footnote area keep room for their space after and bottom border, and automatic-width tables keep their outer borders inside the text column and size columns for tracked deleted content. PDF export completes long documents within the page limit and finds installed Garamond fonts. +Justified lines compress spaces in more cases, paragraphs and table rows above a page end or footnote area keep room for their space after and bottom border, and automatic-width tables keep their outer borders inside the text column and size columns for tracked deleted content. PDF export completes long documents within the page limit and finds installed Garamond and large CJK fonts such as SimSun. From daca75fa57fe8d1dba74aaa00c3e0f8e031a87a1 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 08:21:27 +0200 Subject: [PATCH 22/25] refactor: address review of justified, footnote, and table fit changes Read the markup structure only for tables with revisions, limit the auto-width shrink to the outer rule, let a splittable next row keep the row above, scope the fixed-result mark to REF results, keep the PDF shaping default under a partial fontShaping, and add incremental-layout tests. Co-Authored-By: Claude Opus 5.5 --- .changeset/justified-lines-footnote-fit.md | 2 +- docs/site/content/export/pdf/api.mdx | 2 +- .../__tests__/field-button-projection.test.ts | 2 +- .../field-doc-property-projection.test.ts | 2 +- .../__tests__/field-symbol-projection.test.ts | 2 +- .../footnote-space-after-fit.test.ts | 42 ++++++++- .../table-auto-width-outer-box.test.ts | 48 ++++++++-- .../table-width-moved-row-compare.test.ts | 1 + packages/core/src/layout/field-pieces.ts | 4 +- .../core/src/layout/field-projection-walk.ts | 2 +- packages/core/src/layout/note-area-fit.ts | 37 ++++++-- packages/core/src/layout/semantic-layout.ts | 11 ++- packages/core/src/layout/semantic-table.ts | 3 +- .../core/src/layout/table-autofit-widths.ts | 8 +- .../core/src/layout/table-flow-pagination.ts | 18 ++-- .../core/src/layout/table-revision-markup.ts | 28 ++++++ .../src/layout/table-terminal-border-plan.ts | 89 ++++++++++++++----- packages/docx-to-pdf/docs/api.md | 2 +- packages/docx-to-pdf/src/open-session.ts | 7 +- packages/docx-to-pdf/src/types.ts | 9 +- .../test/export-font-shaping.test.ts | 6 ++ 21 files changed, 256 insertions(+), 69 deletions(-) create mode 100644 packages/core/src/layout/table-revision-markup.ts diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md index 6a0437f07..c109a0a9c 100644 --- a/.changeset/justified-lines-footnote-fit.md +++ b/.changeset/justified-lines-footnote-fit.md @@ -3,4 +3,4 @@ '@docx-editor.dev/docx-to-pdf': patch --- -Justified lines compress spaces in more cases, paragraphs and table rows above a page end or footnote area keep room for their space after and bottom border, and automatic-width tables keep their outer borders inside the text column and size columns for tracked deleted content. PDF export completes long documents within the page limit and finds installed Garamond and large CJK fonts such as SimSun. +Long documents with justified text, footnotes, and tables 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/site/content/export/pdf/api.mdx b/docs/site/content/export/pdf/api.mdx index 23d2be8f5..95033840a 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__/field-button-projection.test.ts b/packages/core/src/layout/__tests__/field-button-projection.test.ts index 2e50dd068..596bbf2d9 100644 --- a/packages/core/src/layout/__tests__/field-button-projection.test.ts +++ b/packages/core/src/layout/__tests__/field-button-projection.test.ts @@ -75,7 +75,7 @@ describe('a complex MACROBUTTON field', () => { expect(pieces.map((piece) => piece.text)).toEqual(['A', 'Click Here', 'B']); const button = pieces[1]!; expect(button).toMatchObject({ start: 1, end: 2, projected: true }); - expect(button.fieldAtom).toEqual({ formField: false, fixedResult: true }); + expect(button.fieldAtom).toEqual({ formField: false }); expect(pieces[2]).toMatchObject({ start: 2, end: 3 }); }); diff --git a/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts b/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts index 59d6669a8..97c6fc7a8 100644 --- a/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts +++ b/packages/core/src/layout/__tests__/field-doc-property-projection.test.ts @@ -100,7 +100,7 @@ describe('a complex document-property field with no cached result', () => { ); expect(pieces.map((piece) => piece.text)).toEqual(['A', value, 'B']); expect(pieces[1]).toMatchObject({ start: 1, end: 2, projected: true }); - expect(pieces[1]!.fieldAtom).toEqual({ formField: false, fixedResult: true }); + expect(pieces[1]!.fieldAtom).toEqual({ formField: false }); }); } diff --git a/packages/core/src/layout/__tests__/field-symbol-projection.test.ts b/packages/core/src/layout/__tests__/field-symbol-projection.test.ts index 3de632331..9c669f313 100644 --- a/packages/core/src/layout/__tests__/field-symbol-projection.test.ts +++ b/packages/core/src/layout/__tests__/field-symbol-projection.test.ts @@ -90,7 +90,7 @@ describe('a complex SYMBOL field', () => { const glyph = pieces[1]!; expect(glyph).toMatchObject({ start: 1, end: 2, projected: true }); expect(glyph.style.fontFamily).toBe('Wingdings'); - expect(glyph.fieldAtom).toEqual({ formField: false, fixedResult: true }); + expect(glyph.fieldAtom).toEqual({ formField: false }); expect(pieces[2]).toMatchObject({ start: 2, end: 3 }); }); 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 index ac0dcd4e9..f09ccd2aa 100644 --- a/packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts +++ b/packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts @@ -10,6 +10,7 @@ import { 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'; @@ -24,7 +25,7 @@ const run = (text: string) => `${text} `${run(text)}`; -function layout(body: string) { +function load(body: string) { const entries = { '[Content_Types].xml': ``, '_rels/.rels': ``, @@ -41,9 +42,9 @@ function layout(body: string) { const fn = resolveFootnoteProperties(undefined, undefined); const en = resolveEndnoteProperties(undefined, undefined); const measurer = createFixedMeasurer(5, LINE); - return layoutSemanticDocument(part, 1, { + const options = (session = createLayoutSession()) => ({ measurer, - session: createLayoutSession(), + session, notes: { footnotesPart: resolveNotesPart(loaded.package, 'footnote'), endnotesPart: null, @@ -55,6 +56,12 @@ function layout(body: string) { 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 => @@ -99,3 +106,32 @@ describe('space after above a footnote area', () => { 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__/table-auto-width-outer-box.test.ts b/packages/core/src/layout/__tests__/table-auto-width-outer-box.test.ts index a73205dd9..3c83aba46 100644 --- 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 @@ -1,5 +1,8 @@ import { expect, test } from 'bun:test'; -import { readOoxmlPart } from '../../store/package/ooxml-tree.ts'; +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'; @@ -7,7 +10,7 @@ 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 grid(columns: readonly number[], texts: readonly string[], jc: string) { +function source(columns: readonly number[], texts: readonly string[], jc: string) { const cells = columns .map( (width, index) => @@ -23,10 +26,13 @@ function grid(columns: readonly number[], texts: readonly string[], jc: string) { 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, - }); + 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' )!; @@ -46,3 +52,33 @@ for (const jc of ['left', 'center', 'right']) { expect(total(grid([4000, 4360, 1000], ['a', 'b', 'x'.repeat(16)], jc))).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-width-moved-row-compare.test.ts b/packages/core/src/layout/__tests__/table-width-moved-row-compare.test.ts index 4d7f71ed0..d5df2eaf5 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 @@ -138,6 +138,7 @@ test('compound, thick and dashed rules with each page keeping its own bottom edg // 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); + expect(comparisons.refused).toBe(0); }); test('mixed cell rules override the table rules on some rows', () => { diff --git a/packages/core/src/layout/field-pieces.ts b/packages/core/src/layout/field-pieces.ts index 17189db38..61c86fa34 100644 --- a/packages/core/src/layout/field-pieces.ts +++ b/packages/core/src/layout/field-pieces.ts @@ -65,8 +65,8 @@ export interface PositionalTab { */ export interface FieldAtomMarker { /** - * The field shows a result whose width pagination never changes (a cross-reference - * number, not a page number), so a justified line may treat it as an ordinary word. + * A REF cross-reference with no nested field: its result is the target's text or number, + * which pagination never changes, so a justified line may treat it as an ordinary word. */ readonly fixedResult?: true; /** diff --git a/packages/core/src/layout/field-projection-walk.ts b/packages/core/src/layout/field-projection-walk.ts index a0b05d96f..28b0fe209 100644 --- a/packages/core/src/layout/field-projection-walk.ts +++ b/packages/core/src/layout/field-projection-walk.ts @@ -270,7 +270,7 @@ export function unmergedPiecesOfParagraphForDisplay( fieldAtom: { formField, ...(formControl ? { formControl } : {}), - ...(pending?.kind === null && !formField ? { fixedResult: true as const } : {}), + ...(pending?.refSpec && !pending.hasNestedField ? { fixedResult: true as const } : {}), }, }; return carriedMemo; diff --git a/packages/core/src/layout/note-area-fit.ts b/packages/core/src/layout/note-area-fit.ts index 2bac395cc..72db48979 100644 --- a/packages/core/src/layout/note-area-fit.ts +++ b/packages/core/src/layout/note-area-fit.ts @@ -25,9 +25,33 @@ export function pageHoldsFootnoteReference( line: SpannedLine ): boolean { if (citesFootnote(line) || pending.some(citesFootnote)) return true; - return fragments.some( - (fragment) => fragment.kind === 'paragraph' && fragment.lines.some(citesFootnote) - ); + 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; } /** @@ -46,8 +70,7 @@ export function chargedSpaceAfterPt(page: PageRecord): number { } } if (last?.kind !== 'paragraph' || !last.paragraphEnd) return 0; - const cites = page.fragments.some( - (fragment) => fragment.kind === 'paragraph' && fragment.lines.some(citesFootnote) - ); - return cites ? last.spacing.after : 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/semantic-layout.ts b/packages/core/src/layout/semantic-layout.ts index e0bc4bc9f..5810efa36 100644 --- a/packages/core/src/layout/semantic-layout.ts +++ b/packages/core/src/layout/semantic-layout.ts @@ -192,7 +192,7 @@ import { layoutSemanticDocumentWithNotes, notesReserveContextKey, } from './note-pagination.ts'; -import { pageHoldsFootnoteReference } from './note-area-fit.ts'; +import { closingSpaceAfterPt } from './note-area-fit.ts'; import { passProducerOf } from './pass-producer.ts'; import { documentProjectionProducer } from './document-property-context.ts'; @@ -681,6 +681,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); @@ -2223,6 +2224,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 @@ -2505,11 +2508,7 @@ function* layoutBlocksPass( // 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. Above a footnote area it must fit too. - const noteArea = - isLastLine && - (pageBottomReserves?.get(pageIndexStart + pages.length) ?? 0) > 0 && - pageHoldsFootnoteReference(pageFragments, pending, pendingLine); - const tail = isLastLine ? borderExtent + (noteArea ? spacing.after : 0) : 0; + 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 a0bd5853a..4a8ea445d 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, @@ -413,7 +414,7 @@ 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' + displayMode === 'all-markup' || !tableHasHidableRevisions(table) ? undefined : readTableStructure( table, diff --git a/packages/core/src/layout/table-autofit-widths.ts b/packages/core/src/layout/table-autofit-widths.ts index e4aa0eb4c..4033993e4 100644 --- a/packages/core/src/layout/table-autofit-widths.ts +++ b/packages/core/src/layout/table-autofit-widths.ts @@ -908,12 +908,16 @@ export function autofitColumnWidthsPt( view.depth ?? 0 ); if (spread || (!widens && !sizedByContent)) { + // 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) - : structure.autoOuterRulePt + : rule > 0 ? sameOrComputed( structure, - widenAutofitColumns(cellWidths, minimumsNow, availableFor(roomWidthPt), Infinity, true) + widenAutofitColumns(cellWidths, minimumsNow, shrinkTo, Infinity, true) ) : structure.columnWidthsPt; byStructure.set(structure, { contentWidthPt, widths }); diff --git a/packages/core/src/layout/table-flow-pagination.ts b/packages/core/src/layout/table-flow-pagination.ts index c59bccb6d..904821074 100644 --- a/packages/core/src/layout/table-flow-pagination.ts +++ b/packages/core/src/layout/table-flow-pagination.ts @@ -30,7 +30,7 @@ import { import { closingCellSpacingPt } from './table-cell-spacing.ts'; import { vMergePlanFor } from './table-vmerge-plan.ts'; import { - closingEdgeOverflowHeight, + closingEdgePt, terminalBorderPlanAt, type TerminalRowAt, } from './table-terminal-border-plan.ts'; @@ -646,17 +646,21 @@ export function* paginateTableInFlowSteps( if (rows.length === 0 && clearance.clear(clearance.pending)) admitSpans(bodyRowIndex, row); } - const terminalAt = (): TerminalRowAt => - [structure, row, tableLeft, flow.cursorY, contentHeight(), rowDeps()] as const; + 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) return; if (naturalHeight <= contentHeight() - flow.cursorY + 0.001) { - const [next, below] = [bodyRows[bodyRowIndex + 1], flow.cursorY + naturalHeight]; - const fits = (room: number) => !!next && rowHeightOf(next, below) <= room; - const closed = closingEdgeOverflowHeight(terminalAt(), naturalHeight, fits); - closingEdge = closed === undefined ? 0 : closed - naturalHeight; + const next = bodyRows[bodyRowIndex + 1]; + closingEdge = closingEdgePt(terminalAt(), naturalHeight, next, tableDeps, rowHeightOf); return; } const plan = terminalBorderPlanAt(terminalAt()); 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 000000000..8c49a3819 --- /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 34603d180..f13f63bb2 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, @@ -29,41 +30,50 @@ export function prepareTerminalBorderPlan( return measured && !measured.overflows ? measured.plan : undefined; } -/** A row's placement: its table, the row, the table's left edge, its top, the page bottom. */ -export type TerminalRowAt = readonly [ - SemanticTableStructure, - SemanticTableRow, - number, - number, - number, - TableFlowDeps, -]; +/** 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[0], at[1], at[2], at[3], at[4], at[5]); + 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: compound rules included. */ +/** + * Room below a fitting row in which its closing border is checked. Wider than the widest + * compound border (thick-thin-thick rules reach about 18pt) and narrower than any row that + * could follow, so a row with more room never ends the page here. + */ const CLOSING_EDGE_BAND_PT = 18; /** - * The height a row that fits at `naturalHeight` must count instead: a row that ends its page - * closes it with its own bottom border, which must fit above `bottom` too. Undefined when - * the row fits that way, keeps the edge it already has, or a following row fits below it - * (`nextFits`), so it does not end the page. + * 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 closingEdgeOverflowHeight( +export function closingEdgePt( at: TerminalRowAt, naturalHeight: number, - nextFits: (room: number) => boolean -): number | undefined { - const [structure, row, left, top, bottom, deps] = at; - const room = bottom - top - naturalHeight; - if (room < -0.001 || room > CLOSING_EDGE_BAND_PT) return undefined; - const measured = measureTerminalRow(structure, row, left, top, bottom, deps); - if (!measured?.overflows || nextFits(room)) return undefined; - return measured.plan.height; + 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( @@ -127,3 +137,34 @@ 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. + */ +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 deb1050ce..62beafb0f 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 -Text shaping accepts font faces up to `41943040` bytes (40 MiB) unless you set `fontShaping`, so large installed CJK fonts are not replaced. The writer limits diagnostics to `10000`. It limits paint operations to `2000000` or 6,000 for each laid-out page, whichever is greater, up to `40000000`. It limits uncompressed content to `67108864` bytes or 196,608 bytes for each page, whichever is greater, up to `536870912` 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/src/open-session.ts b/packages/docx-to-pdf/src/open-session.ts index 32563f865..9f2fdeb61 100644 --- a/packages/docx-to-pdf/src/open-session.ts +++ b/packages/docx-to-pdf/src/open-session.ts @@ -31,11 +31,14 @@ 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, or the PDF export default. */ +/** 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: PDF_MAX_SHAPING_FONT_BYTES }; + 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. */ diff --git a/packages/docx-to-pdf/src/types.ts b/packages/docx-to-pdf/src/types.ts index 83b4e6764..88110f374 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 index 6b243b9e1..abafeafb3 100644 --- a/packages/docx-to-pdf/test/export-font-shaping.test.ts +++ b/packages/docx-to-pdf/test/export-font-shaping.test.ts @@ -17,3 +17,9 @@ test('an explicit shaping limit wins', () => { maxFontBytes: 1024, }); }); + +test('an empty shaping object keeps the PDF export default', () => { + expect(exportFontShaping({ fontShaping: {} })).toEqual({ + maxFontBytes: PDF_MAX_SHAPING_FONT_BYTES, + }); +}); From e48166be73c5d6b8d9787e476495d4a179e210b2 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 08:25:24 +0200 Subject: [PATCH 23/25] fix(core): keep page and note references out of the justified shrink PAGEREF and NOTEREF results can change width after the line is decided, so only a plain REF result counts as a fixed word. Refresh the API snapshot, fix the closing-edge band comment, and cover a splittable next row and a wide grid. Co-Authored-By: Claude Opus 5.5 --- .changeset/justified-lines-footnote-fit.md | 2 +- docs/api/docx-editor-core/layout.api.md | 2 ++ docs/api/docx-editor-docx-to-pdf/index.api.md | 3 ++- .../paragraph-space-shrink-terminal.test.ts | 3 +++ .../__tests__/table-auto-width-outer-box.test.ts | 5 +++++ .../__tests__/table-closing-border-fit.test.ts | 12 +++++++----- packages/core/src/layout/field-pieces.ts | 5 +++-- packages/core/src/layout/field-projection-walk.ts | 5 ++++- packages/core/src/layout/field-ref-parse.ts | 9 +++++++++ .../core/src/layout/table-terminal-border-plan.ts | 7 ++++--- 10 files changed, 40 insertions(+), 13 deletions(-) diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md index c109a0a9c..78afd5502 100644 --- a/.changeset/justified-lines-footnote-fit.md +++ b/.changeset/justified-lines-footnote-fit.md @@ -1,5 +1,5 @@ --- -'@docx-editor.dev/core': patch +'@docx-editor.dev/core': minor '@docx-editor.dev/docx-to-pdf': patch --- diff --git a/docs/api/docx-editor-core/layout.api.md b/docs/api/docx-editor-core/layout.api.md index 31c18772c..8193bad08 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 cd1586dd5..42680263d 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/packages/core/src/layout/__tests__/paragraph-space-shrink-terminal.test.ts b/packages/core/src/layout/__tests__/paragraph-space-shrink-terminal.test.ts index cd05a3472..6194f8725 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 @@ -103,6 +103,9 @@ test('a hard break, trailing tab, or page field after or as the last word keeps 'aa bb cc ', 'aa bb 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'); 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 index 3c83aba46..4bb52d7d3 100644 --- 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 @@ -53,6 +53,11 @@ for (const jc of ['left', 'center', 'right']) { }); } +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(); 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 index 3a8959145..c7513651b 100644 --- a/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts +++ b/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts @@ -12,13 +12,13 @@ const paragraph = (text: string) => 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() { +function table(lastRowLines = 1) { const parsed = readOoxmlPart( ` ${edges.map((edge) => ``).join('')} ${['top', 'bottom', 'left', 'right'].map((s) => ``).join('')} - ${['One', 'Two', 'Three'].map((text) => `${paragraph(text)}`).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' } ); @@ -26,8 +26,8 @@ function table() { return parsed.part; } -function firstPageRows(height: number): string[] { - const result = layoutSemanticDocument(table(), 0, { +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 } }, @@ -55,5 +55,7 @@ test('a row that ends the page needs room for its closing border', () => { }); test('a row followed on the page by another row keeps its place', () => { - expect(firstPageRows(38)).toEqual(['One', 'Two', 'Three']); + 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']); }); diff --git a/packages/core/src/layout/field-pieces.ts b/packages/core/src/layout/field-pieces.ts index 61c86fa34..fc18bcfb1 100644 --- a/packages/core/src/layout/field-pieces.ts +++ b/packages/core/src/layout/field-pieces.ts @@ -65,8 +65,9 @@ export interface PositionalTab { */ export interface FieldAtomMarker { /** - * A REF cross-reference with no nested field: its result is the target's text or number, - * which pagination never changes, so a justified line may treat it as an ordinary word. + * 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; /** diff --git a/packages/core/src/layout/field-projection-walk.ts b/packages/core/src/layout/field-projection-walk.ts index 28b0fe209..080635b24 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 { @@ -270,7 +271,9 @@ export function unmergedPiecesOfParagraphForDisplay( fieldAtom: { formField, ...(formControl ? { formControl } : {}), - ...(pending?.refSpec && !pending.hasNestedField ? { fixedResult: true as const } : {}), + ...(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 e5f6cbc26..75fb96305 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/table-terminal-border-plan.ts b/packages/core/src/layout/table-terminal-border-plan.ts index f13f63bb2..0b3e4f268 100644 --- a/packages/core/src/layout/table-terminal-border-plan.ts +++ b/packages/core/src/layout/table-terminal-border-plan.ts @@ -46,9 +46,8 @@ export function terminalBorderPlanAt(at: TerminalRowAt): TerminalBorderPlan | un } /** - * Room below a fitting row in which its closing border is checked. Wider than the widest - * compound border (thick-thin-thick rules reach about 18pt) and narrower than any row that - * could follow, so a row with more room never ends the page here. + * 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; @@ -141,6 +140,8 @@ 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, From 31d1101fba660b26ec9dfd36330ec795611e8021 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 08:27:08 +0200 Subject: [PATCH 24/25] test(core): a splittable next row keeps a row above a thick bottom rule Co-Authored-By: Claude Opus 5.5 --- .../table-closing-border-fit.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 index c7513651b..5c180291a 100644 --- a/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts +++ b/packages/core/src/layout/__tests__/table-closing-border-fit.test.ts @@ -59,3 +59,39 @@ test('a row followed on the page by another row keeps its place', () => { // 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); +}); From dfc835e6568575e5f15582a224797f63efa24858 Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Thu, 15 Oct 2026 15:20:45 +0200 Subject: [PATCH 25/25] fix(core): return lines ahead of a held footnote reference and give SimSun its line height The footnote hold-out kept a whole paragraph continuation off the previous page when its one reference line could not return. Lines ahead of the reference now return, as far as widow and orphan control allow. One-em CJK faces with a small built-in gap now take the same 1.3 em single line as faces without one. The section flow keys and the table read prelude move to their own modules to keep the layout files under their caps. Co-Authored-By: Claude Opus 5.5 --- .changeset/justified-lines-footnote-fit.md | 2 +- .../footnote-holdout-frontier.test.ts | 4 +- .../footnote-holdout-leading-lines.test.ts | 160 ++++++++++++++++++ .../__tests__/legacy-cjk-line-metrics.test.ts | 14 +- .../src/layout/legacy-cjk-line-metrics.ts | 25 ++- .../core/src/layout/note-reserve-holdout.ts | 60 ++++++- packages/core/src/layout/section-flow-keys.ts | 83 +++++++++ packages/core/src/layout/semantic-layout.ts | 71 +++----- .../core/src/layout/table-flow-pagination.ts | 33 +--- .../core/src/layout/table-flow-structure.ts | 47 +++++ 10 files changed, 402 insertions(+), 97 deletions(-) create mode 100644 packages/core/src/layout/__tests__/footnote-holdout-leading-lines.test.ts create mode 100644 packages/core/src/layout/section-flow-keys.ts create mode 100644 packages/core/src/layout/table-flow-structure.ts diff --git a/.changeset/justified-lines-footnote-fit.md b/.changeset/justified-lines-footnote-fit.md index 78afd5502..c4e0a5b34 100644 --- a/.changeset/justified-lines-footnote-fit.md +++ b/.changeset/justified-lines-footnote-fit.md @@ -3,4 +3,4 @@ '@docx-editor.dev/docx-to-pdf': patch --- -Long documents with justified text, footnotes, and tables now break lines and pages more accurately. PDF export now completes long documents and uses installed Garamond and large CJK fonts. +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/packages/core/src/layout/__tests__/footnote-holdout-frontier.test.ts b/packages/core/src/layout/__tests__/footnote-holdout-frontier.test.ts index 534eeb618..81840b621 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 000000000..f020f73a2 --- /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__/legacy-cjk-line-metrics.test.ts b/packages/core/src/layout/__tests__/legacy-cjk-line-metrics.test.ts index 4fa93aa9a..a6c4873fc 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/legacy-cjk-line-metrics.ts b/packages/core/src/layout/legacy-cjk-line-metrics.ts index fe3c58f06..556f8b429 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/note-reserve-holdout.ts b/packages/core/src/layout/note-reserve-holdout.ts index 34daf2d75..b02ca649a 100644 --- a/packages/core/src/layout/note-reserve-holdout.ts +++ b/packages/core/src/layout/note-reserve-holdout.ts @@ -396,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; @@ -420,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/section-flow-keys.ts b/packages/core/src/layout/section-flow-keys.ts new file mode 100644 index 000000000..16acfa547 --- /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 5810efa36..01abe7a13 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, @@ -238,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 & { @@ -1004,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, diff --git a/packages/core/src/layout/table-flow-pagination.ts b/packages/core/src/layout/table-flow-pagination.ts index 904821074..ed3a8e682 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'; @@ -54,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'; @@ -97,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 ( 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 000000000..36366d5ff --- /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 }; +}