feat: introduce columns
This commit is contained in:
1 parent
4c045135c1
commit
de283adab5
18 files changed
+1801
-35
No files matched your search
+1
-1
@@ -51,6 +51,7 @@
|
||||
|
||||
**Page & layout**
|
||||
- Page margins (cm) and page orientation (portrait / landscape)
|
||||
- Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols`
|
||||
- Headers & footers with page-number / page-count fields and configurable edge distances
|
||||
- Zoom (20–300 %)
|
||||
- Show formatting marks (spaces, tabs, paragraph marks)
|
||||
@@ -81,7 +82,6 @@
|
||||
Planned word-processor features (to match Word/LibreOffice):
|
||||
- Footnotes / endnotes (currently dropped on import)
|
||||
- Clear formatting (remove all marks)
|
||||
- Multi-column (newspaper) layout
|
||||
- Comments / annotations (currently dropped on import)
|
||||
- Horizontal rule
|
||||
- Page numbering options (start value / format) + different first-page header
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { importOdt } from './lib/import/odt';
|
||||
import { importDocx } from './lib/import/docx';
|
||||
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
|
||||
import { getColumnsFlowDebug } from './lib/editor/extensions/columnsFlow';
|
||||
import { getColorDebug } from './lib/utils/colorDebug';
|
||||
import { countText, type TextStats } from './lib/utils/wordCount';
|
||||
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
|
||||
@@ -557,6 +558,7 @@
|
||||
zoom,
|
||||
doc: editor.getJSON(),
|
||||
pageBreaks: snapshot,
|
||||
columnsFlow: getColumnsFlowDebug(editor.view),
|
||||
colors: getColorDebug(editor),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
|
||||
import { listContext } from '../editor/extensions/indent';
|
||||
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../editor/extensions/columns';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import { withShortcut } from '../i18n/shortcut';
|
||||
|
||||
@@ -612,6 +613,7 @@
|
||||
lineHeightOpen = false;
|
||||
fontColorOpen = false;
|
||||
highlightColorOpen = false;
|
||||
columnsOpen = false;
|
||||
layoutOpen = !layoutOpen;
|
||||
if (layoutOpen) {
|
||||
syncMarginInputs();
|
||||
@@ -635,6 +637,7 @@
|
||||
sizeOpen = false;
|
||||
sizeInputFocused = false;
|
||||
lineHeightOpen = false;
|
||||
columnsOpen = false;
|
||||
if (which === 'font') highlightColorOpen = false;
|
||||
else fontColorOpen = false;
|
||||
}
|
||||
@@ -652,6 +655,7 @@
|
||||
highlightColorOpen = false;
|
||||
layoutOpen = false;
|
||||
specialCharOpen = false;
|
||||
columnsOpen = false;
|
||||
}
|
||||
|
||||
function insertTable(rows: number, cols: number, range: { from: number; to: number }) {
|
||||
@@ -665,6 +669,86 @@
|
||||
editor.chain().focus().setTableOfContents().run();
|
||||
}
|
||||
|
||||
// --- Multi-column section (columns.ts) ---
|
||||
let columnsOpen = $state(false);
|
||||
let columnGapInput = $state('');
|
||||
|
||||
// Count/gap of the columns section around the cursor; count 1 = not in one.
|
||||
let colState = $derived.by(() => {
|
||||
if (tick < 0 || !editor) return { count: 1, gapCm: DEFAULT_COLUMN_GAP_CM, inColumns: false };
|
||||
const found = findColumns(editor.state);
|
||||
return found
|
||||
? { count: found.node.attrs.count as number, gapCm: found.node.attrs.gapCm as number, inColumns: true }
|
||||
: { count: 1, gapCm: DEFAULT_COLUMN_GAP_CM, inColumns: false };
|
||||
});
|
||||
|
||||
function openColumnsPicker() {
|
||||
fontOpen = false;
|
||||
sizeOpen = false;
|
||||
sizeInputFocused = false;
|
||||
lineHeightOpen = false;
|
||||
fontColorOpen = false;
|
||||
highlightColorOpen = false;
|
||||
layoutOpen = false;
|
||||
tableOpen = false;
|
||||
specialCharOpen = false;
|
||||
columnsOpen = !columnsOpen;
|
||||
if (columnsOpen) columnGapInput = fmtCm(colState.gapCm);
|
||||
}
|
||||
|
||||
function columnsClickOutside(node: HTMLElement) {
|
||||
function handler(e: MouseEvent) {
|
||||
if (!node.contains(e.target as Node)) columnsOpen = false;
|
||||
}
|
||||
window.addEventListener('mousedown', handler);
|
||||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||||
}
|
||||
|
||||
// The current gap straight from the document (colState may not have recomputed yet
|
||||
// within the same handler after a command ran).
|
||||
function liveColumnGap(): number {
|
||||
const found = editor ? findColumns(editor.state) : null;
|
||||
return (found?.node.attrs.gapCm as number | undefined) ?? DEFAULT_COLUMN_GAP_CM;
|
||||
}
|
||||
|
||||
function applyColumnCount(n: number) {
|
||||
if (!editor || hfActive) return;
|
||||
editor.chain().focus().setColumns(n).run();
|
||||
columnGapInput = fmtCm(liveColumnGap());
|
||||
}
|
||||
|
||||
function setColumnGap(value: number) {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setColumnGap(value).run();
|
||||
columnGapInput = fmtCm(liveColumnGap());
|
||||
}
|
||||
|
||||
function commitColumnGapInput() {
|
||||
const n = parseFloat(columnGapInput);
|
||||
if (Number.isNaN(n)) {
|
||||
columnGapInput = fmtCm(liveColumnGap()); // revert invalid input
|
||||
return;
|
||||
}
|
||||
setColumnGap(n);
|
||||
}
|
||||
|
||||
function onColumnGapKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitColumnGapInput();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
columnGapInput = fmtCm(liveColumnGap());
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setColumnGap(liveColumnGap() + MARGIN_STEP);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setColumnGap(liveColumnGap() - MARGIN_STEP);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Special character insertion (SpecialCharPicker.svelte) ---
|
||||
let specialCharOpen = $state(false);
|
||||
|
||||
@@ -678,6 +762,7 @@
|
||||
highlightColorOpen = false;
|
||||
layoutOpen = false;
|
||||
tableOpen = false;
|
||||
columnsOpen = false;
|
||||
}
|
||||
|
||||
function insertSpecialChar(char: string, range: { from: number; to: number }) {
|
||||
@@ -1238,6 +1323,95 @@
|
||||
<path d="M5.5 6.5h5M8 6.5v4" stroke="currentColor" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="columns-wrap" use:columnsClickOutside>
|
||||
<button
|
||||
class:active={colState.inColumns}
|
||||
onclick={openColumnsPicker}
|
||||
disabled={!!hfActive}
|
||||
title={hfActive ? t().toolbarExpanded.columnsNotInHf : t().toolbarExpanded.columns}
|
||||
aria-label={t().toolbarExpanded.columns}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={columnsOpen}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M2 3.5h5M2 6h5M2 8.5h5M2 11h5M2 13.5h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
<path d="M9 3.5h5M9 6h5M9 8.5h5M9 11h5M9 13.5h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if columnsOpen}
|
||||
<div class="columns-dropdown">
|
||||
<div class="lh-section-label">{t().toolbarExpanded.columns}</div>
|
||||
<div class="columns-row">
|
||||
<button
|
||||
class="orientation-btn"
|
||||
class:active={colState.count === 1}
|
||||
aria-pressed={colState.count === 1}
|
||||
onclick={() => applyColumnCount(1)}
|
||||
title={t().toolbarExpanded.columnsOne}
|
||||
>
|
||||
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" aria-hidden="true">
|
||||
<path d="M3 3h16M3 6h16M3 9h16M3 12h16M3 15h10" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>1</span>
|
||||
</button>
|
||||
<button
|
||||
class="orientation-btn"
|
||||
class:active={colState.count === 2}
|
||||
aria-pressed={colState.count === 2}
|
||||
onclick={() => applyColumnCount(2)}
|
||||
title={t().toolbarExpanded.columnsTwo}
|
||||
>
|
||||
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" aria-hidden="true">
|
||||
<path d="M3 3h7M3 6h7M3 9h7M3 12h7M3 15h5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
<path d="M12 3h7M12 6h7M12 9h7M12 12h7M12 15h5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>2</span>
|
||||
</button>
|
||||
<button
|
||||
class="orientation-btn"
|
||||
class:active={colState.count === 3}
|
||||
aria-pressed={colState.count === 3}
|
||||
onclick={() => applyColumnCount(3)}
|
||||
title={t().toolbarExpanded.columnsThree}
|
||||
>
|
||||
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" aria-hidden="true">
|
||||
<path d="M3 3h4M3 6h4M3 9h4M3 12h4M3 15h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
<path d="M9 3h4M9 6h4M9 9h4M9 12h4M9 15h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
<path d="M15 3h4M15 6h4M15 9h4M15 12h4M15 15h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>3</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="lh-section-label">{t().toolbarExpanded.columnGap}</div>
|
||||
<div class="columns-gap-row">
|
||||
<div class="margin-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="margin-input"
|
||||
bind:value={columnGapInput}
|
||||
disabled={!colState.inColumns}
|
||||
onkeydown={onColumnGapKeydown}
|
||||
onblur={commitColumnGapInput}
|
||||
inputmode="decimal"
|
||||
title={t().toolbarExpanded.columnGap}
|
||||
/>
|
||||
<div class="margin-steppers">
|
||||
<button class="margin-step" tabindex="-1" disabled={!colState.inColumns} onclick={() => setColumnGap(liveColumnGap() + MARGIN_STEP)} title={t().toolbarExpanded.increaseShort} aria-label={t().toolbarExpanded.increase(t().toolbarExpanded.columnGap)}>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="margin-step" tabindex="-1" disabled={!colState.inColumns} onclick={() => setColumnGap(liveColumnGap() - MARGIN_STEP)} title={t().toolbarExpanded.decreaseShort} aria-label={t().toolbarExpanded.decrease(t().toolbarExpanded.columnGap)}>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={insertToc}
|
||||
disabled={!!hfActive}
|
||||
@@ -1731,6 +1905,34 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.columns-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.columns-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 0;
|
||||
width: 200px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
z-index: 200;
|
||||
padding: 2px 2px 6px;
|
||||
}
|
||||
|
||||
.columns-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 6px;
|
||||
padding: 4px 6px 2px;
|
||||
}
|
||||
|
||||
.columns-gap-row {
|
||||
padding: 4px 6px 2px;
|
||||
}
|
||||
|
||||
.margin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -41,11 +41,14 @@ import { TableHeaderRow } from './extensions/tableHeaderRow';
|
||||
import { TrailingNode } from './extensions/trailingNode';
|
||||
import { Image } from './extensions/image';
|
||||
import { TextBox } from './extensions/textBox';
|
||||
import { Columns } from './extensions/columns';
|
||||
import { ColumnsFlow } from './extensions/columnsFlow';
|
||||
import { TableOfContents } from './extensions/tableOfContents';
|
||||
|
||||
export const extensions = [
|
||||
// textBox has its own group so only the document (not cells/lists) admits it.
|
||||
Document.extend({ content: '(block | textBox)+' }),
|
||||
// textBox and columns have their own groups so only the document (not
|
||||
// cells/lists/boxes) admits them.
|
||||
Document.extend({ content: '(block | textBox | columns)+' }),
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
@@ -73,6 +76,11 @@ export const extensions = [
|
||||
// Block-level text box / basic shape with editable content; round-trips to ODF
|
||||
// draw:frame/draw:text-box + draw:custom-shape and DOCX wps:wsp/wps:txbx.
|
||||
TextBox,
|
||||
// Multi-column (newspaper) section; round-trips to ODF <text:section> +
|
||||
// style:columns and DOCX continuous sections with w:cols. ColumnsFlow splits a
|
||||
// section into per-page fragments so the text flows across page breaks.
|
||||
Columns,
|
||||
ColumnsFlow,
|
||||
// Generated table of contents from headings; round-trips to ODF
|
||||
// <text:table-of-content> (export/odt.ts, import/odt.ts) and a DOCX TOC field.
|
||||
TableOfContents,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state';
|
||||
|
||||
// A multi-column (newspaper) section: a block region whose content flows through
|
||||
// balanced CSS columns. The document may hold it as several adjacent fragments with
|
||||
// equal attrs — a "chain" — so the flow can continue across page breaks
|
||||
// (columnsFlow.ts splits/joins fragments); export merges a chain into one section.
|
||||
|
||||
export const DEFAULT_COLUMN_GAP_CM = 0.5;
|
||||
export const MAX_COLUMN_GAP_CM = 5;
|
||||
export const MAX_COLUMN_COUNT = 3;
|
||||
|
||||
export interface ColumnsAttrs {
|
||||
count: number; // 2 or 3; a count-1 columns node never exists (1 unwraps)
|
||||
gapCm: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
columns: {
|
||||
setColumns: (count: number) => ReturnType;
|
||||
setColumnGap: (gapCm: number) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Shared fit margin for the "does the first block fit the remaining page space"
|
||||
// test: the balanced-height estimate (block height / count) undershoots the real
|
||||
// fragment height by margins + line quantization. pageBreaks.ts (push vs. wait for
|
||||
// the flow) and columnsFlow.ts (split vs. leave) MUST use the same margin, or the
|
||||
// two disagree at the boundary and split/push/join cycle forever.
|
||||
export const COLUMNS_FIT_MARGIN_PX = 32;
|
||||
|
||||
export function clampColumnCount(n: number): number {
|
||||
return Math.min(MAX_COLUMN_COUNT, Math.max(2, Math.round(n)));
|
||||
}
|
||||
|
||||
export function clampColumnGap(g: number): number {
|
||||
return Math.min(MAX_COLUMN_GAP_CM, Math.max(0, g));
|
||||
}
|
||||
|
||||
export function sameColumnsAttrs(a: PMNode, b: PMNode): boolean {
|
||||
return a.attrs.count === b.attrs.count && a.attrs.gapCm === b.attrs.gapCm;
|
||||
}
|
||||
|
||||
// The columns fragment enclosing the selection (any ancestor of the cursor).
|
||||
export function findColumns(state: EditorState): { pos: number; node: PMNode } | null {
|
||||
const { $from } = state.selection;
|
||||
for (let d = $from.depth; d > 0; d--) {
|
||||
const n = $from.node(d);
|
||||
if (n.type.name === 'columns') return { pos: $from.before(d), node: n };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type Fragment = { pos: number; node: PMNode };
|
||||
|
||||
// The maximal run of adjacent equal-attr fragments containing the one at `pos` —
|
||||
// the user-visible "section". Commands act on the whole chain.
|
||||
export function columnsChain(state: EditorState, pos: number): Fragment[] {
|
||||
const all: Fragment[] = [];
|
||||
state.doc.forEach((node, offset) => {
|
||||
if (node.type.name === 'columns') all.push({ pos: offset, node });
|
||||
});
|
||||
const idx = all.findIndex((f) => f.pos === pos);
|
||||
if (idx < 0) return [];
|
||||
let lo = idx;
|
||||
while (
|
||||
lo > 0 &&
|
||||
all[lo - 1].pos + all[lo - 1].node.nodeSize === all[lo].pos &&
|
||||
sameColumnsAttrs(all[lo - 1].node, all[lo].node)
|
||||
) lo--;
|
||||
let hi = idx;
|
||||
while (
|
||||
hi + 1 < all.length &&
|
||||
all[hi].pos + all[hi].node.nodeSize === all[hi + 1].pos &&
|
||||
sameColumnsAttrs(all[hi].node, all[hi + 1].node)
|
||||
) hi++;
|
||||
return all.slice(lo, hi + 1);
|
||||
}
|
||||
|
||||
const WRAPPABLE = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
|
||||
|
||||
// Top-level blocks of [start, end) as columns content: wrappable blocks collected
|
||||
// as-is, existing columns fragments absorbed (their content joins the run);
|
||||
// null when the range holds anything else (table, text box, TOC).
|
||||
function collectWrappable(state: EditorState, start: number, end: number): { blocks: PMNode[]; from: number; to: number } | null {
|
||||
const blocks: PMNode[] = [];
|
||||
let ok = true;
|
||||
let from = -1;
|
||||
let to = -1;
|
||||
state.doc.nodesBetween(start, end, (n, pos, parent) => {
|
||||
if (parent?.type.name !== 'doc') return false;
|
||||
if (from < 0) from = pos;
|
||||
to = pos + n.nodeSize;
|
||||
if (WRAPPABLE.has(n.type.name)) blocks.push(n);
|
||||
else if (n.type.name === 'columns') blocks.push(...childArray(n));
|
||||
else ok = false;
|
||||
return false;
|
||||
});
|
||||
return ok && blocks.length ? { blocks: mergeJoinedBlocks(blocks), from, to } : null;
|
||||
}
|
||||
|
||||
function childArray(n: PMNode): PMNode[] {
|
||||
const out: PMNode[] = [];
|
||||
n.forEach((c) => out.push(c));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Re-merge paragraphs split by columnsFlow's page-boundary line split (the second
|
||||
// part carries joinPrev). Used wherever fragment content leaves the chain context:
|
||||
// unwrap, absorb-into-a-new-section; export does the same on the JSON level.
|
||||
export function mergeJoinedBlocks(blocks: PMNode[]): PMNode[] {
|
||||
const out: PMNode[] = [];
|
||||
for (const b of blocks) {
|
||||
const prev = out[out.length - 1];
|
||||
if (
|
||||
b.type.name === 'paragraph' && b.attrs.joinPrev &&
|
||||
prev?.type.name === 'paragraph'
|
||||
) {
|
||||
out[out.length - 1] = prev.type.create(prev.attrs, prev.content.append(b.content));
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Word's no-selection behavior: apply columns to the whole document. Wrappable runs
|
||||
// (absorbing existing sections) become one section each; tables/boxes/TOCs stay
|
||||
// between them. Replacements run bottom-up so earlier positions stay valid.
|
||||
function applyToWholeDoc(state: EditorState, tr: Transaction, attrs: ColumnsAttrs): boolean {
|
||||
const type = state.schema.nodes.columns;
|
||||
type Run = { from: number; to: number; blocks: PMNode[] };
|
||||
const runs: Run[] = [];
|
||||
let run: Run | null = null;
|
||||
state.doc.forEach((n, offset) => {
|
||||
if (WRAPPABLE.has(n.type.name) || n.type.name === 'columns') {
|
||||
const blocks = n.type.name === 'columns' ? childArray(n) : [n];
|
||||
if (run) { run.to = offset + n.nodeSize; run.blocks.push(...blocks); }
|
||||
else run = { from: offset, to: offset + n.nodeSize, blocks };
|
||||
} else if (run) {
|
||||
runs.push(run);
|
||||
run = null;
|
||||
}
|
||||
});
|
||||
if (run) runs.push(run);
|
||||
if (!runs.length) return false;
|
||||
for (const r of [...runs].reverse()) {
|
||||
tr.replaceWith(r.from, r.to, type.create(attrs, mergeJoinedBlocks(r.blocks)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const Columns = Node.create({
|
||||
name: 'columns',
|
||||
// Own group (not `block`): only the doc admits it, so it can't nest in table
|
||||
// cells, list items, text boxes, or other columns sections.
|
||||
group: 'columns',
|
||||
content: '(paragraph | heading | bulletList | orderedList)+',
|
||||
// Not isolating/selectable: the section must feel like flowing text, not an
|
||||
// object — selection, Backspace, and joins cross fragment boundaries freely
|
||||
// (a join merges fragments; columnsFlow re-splits as needed).
|
||||
isolating: false,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ['paragraph'],
|
||||
attributes: {
|
||||
// Layout-internal: marks the second part of a paragraph that columnsFlow
|
||||
// split at a page boundary. Never rendered/parsed; export and unwrap
|
||||
// merge a joinPrev paragraph back into its predecessor.
|
||||
joinPrev: {
|
||||
default: false,
|
||||
rendered: false,
|
||||
keepOnSplit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
count: {
|
||||
default: 2,
|
||||
parseHTML: el => {
|
||||
const n = parseInt((el as HTMLElement).getAttribute('data-columns') ?? '', 10);
|
||||
return Number.isFinite(n) ? clampColumnCount(n) : 2;
|
||||
},
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
gapCm: {
|
||||
default: DEFAULT_COLUMN_GAP_CM,
|
||||
parseHTML: el => {
|
||||
const g = parseFloat((el as HTMLElement).getAttribute('data-column-gap') ?? '');
|
||||
return Number.isFinite(g) ? clampColumnGap(g) : DEFAULT_COLUMN_GAP_CM;
|
||||
},
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-columns]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const a = node.attrs as ColumnsAttrs;
|
||||
return ['div', mergeAttributes(HTMLAttributes, {
|
||||
'data-columns': String(a.count),
|
||||
'data-column-gap': String(a.gapCm),
|
||||
class: 'columns-node',
|
||||
style: `column-count:${a.count};column-gap:${a.gapCm}cm`,
|
||||
}), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
// Inside a section: 1 unwraps the whole chain, 2/3 update its count. Outside:
|
||||
// with a selection, 2/3 wrap the covered top-level blocks (fails on tables
|
||||
// etc.); with a bare cursor, the whole document gets columns (Word behavior).
|
||||
setColumns:
|
||||
(count: number) =>
|
||||
({ state, dispatch }) => {
|
||||
const type = state.schema.nodes.columns;
|
||||
if (!type) return false;
|
||||
const found = findColumns(state);
|
||||
if (found) {
|
||||
const chain = columnsChain(state, found.pos);
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
if (count <= 1) {
|
||||
const first = chain[0];
|
||||
const last = chain[chain.length - 1];
|
||||
const blocks = mergeJoinedBlocks(chain.flatMap((f) => childArray(f.node)));
|
||||
tr.replaceWith(first.pos, last.pos + last.node.nodeSize, blocks);
|
||||
} else {
|
||||
const attrs = { ...found.node.attrs, count: clampColumnCount(count) };
|
||||
for (const f of chain) tr.setNodeMarkup(f.pos, undefined, attrs);
|
||||
}
|
||||
dispatch(tr.scrollIntoView());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (count <= 1) return false;
|
||||
const attrs: ColumnsAttrs = { count: clampColumnCount(count), gapCm: DEFAULT_COLUMN_GAP_CM };
|
||||
const { $from, $to, empty } = state.selection;
|
||||
if (empty) {
|
||||
const tr = state.tr;
|
||||
if (!applyToWholeDoc(state, tr, attrs)) return false;
|
||||
if (dispatch) {
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min($from.pos, tr.doc.content.size))));
|
||||
dispatch(tr.scrollIntoView());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if ($from.depth < 1 || $to.depth < 1) return false;
|
||||
const covered = collectWrappable(state, $from.before(1), $to.after(1));
|
||||
if (!covered) return false;
|
||||
if (dispatch) {
|
||||
const tr = state.tr.replaceWith(covered.from, covered.to, type.create(attrs, covered.blocks));
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(covered.from + 1)));
|
||||
dispatch(tr.scrollIntoView());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
setColumnGap:
|
||||
(gapCm: number) =>
|
||||
({ state, dispatch }) => {
|
||||
const found = findColumns(state);
|
||||
if (!found || !Number.isFinite(gapCm)) return false;
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
const attrs = { ...found.node.attrs, gapCm: clampColumnGap(gapCm) };
|
||||
for (const f of columnsChain(state, found.pos)) tr.setNodeMarkup(f.pos, undefined, attrs);
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
import { canJoin, canSplit } from '@tiptap/pm/transform';
|
||||
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { readVerticalMargins, FORCE_PAGE_RECALC } from './pageBreaks';
|
||||
import { sameColumnsAttrs, COLUMNS_FIT_MARGIN_PX } from './columns';
|
||||
|
||||
// Cross-page column flow: the document holds a columns section as adjacent
|
||||
// equal-attr fragments ("chain"). This layout pass keeps the fragmentation in sync
|
||||
// with the page grid — an overflowing fragment is split at a block boundary (or,
|
||||
// for a paragraph taller than the page slot, at a LINE boundary: the second part
|
||||
// carries the layout-internal joinPrev attr and is re-merged by joins/export), and
|
||||
// a fragment with room pulls the continuation back. Splits/joins are pure layout:
|
||||
// they never enter the undo history, and export merges a chain into one section.
|
||||
//
|
||||
// Fill order is sequential like Word (column 1 fills to the page bottom, then
|
||||
// column 2, then the next page): fragments get a node decoration with an explicit
|
||||
// height + column-fill:auto — full page slot for fragments with a continuation,
|
||||
// content height for the open last fragment of a document-final chain (so typing
|
||||
// grows column 1 first). A chain followed by real content keeps CSS balance on its
|
||||
// last fragment (Word balances the end of a continuous section).
|
||||
|
||||
const flowKey = new PluginKey<number>('columnsFlow');
|
||||
const FLOW_TX = 'columnsFlowTx';
|
||||
|
||||
// Split underfill guard: the balanced-height estimate (sum of block heights / count)
|
||||
// ignores line quantization, so leave a little headroom.
|
||||
const SAFETY_PX = 16;
|
||||
// A pull-back (join) must fit by clearly more than the estimate error (~one line +
|
||||
// margins), or a join whose real height exceeds the estimate re-splits forever.
|
||||
const HYSTERESIS_PX = 40;
|
||||
const MIN_BLOCK_PX = 16;
|
||||
const DEFAULT_LINE_PX = 19;
|
||||
// Height slack on the open last fragment: a freshly typed line must still fit
|
||||
// column 1 before the next pass grows the decoration, or every line flashes into
|
||||
// column 2 for one frame.
|
||||
const OPEN_HEIGHT_SLACK_PX = 40;
|
||||
// Below this many lines of leftover room, a boundary block splits at the block
|
||||
// boundary instead of pulling a line or two over (not worth a paragraph split).
|
||||
const MIN_TAIL_LINES = 2;
|
||||
// Pass budget per external change: each pass performs one split/join, so a paste
|
||||
// spanning many pages needs several; bounded against estimate-driven oscillation.
|
||||
const MAX_FLOW_PASSES = 48;
|
||||
|
||||
type Fragment = { pos: number; node: PMNode };
|
||||
|
||||
// Live per-fragment view of the flow's decisions, for the dev Debug dump.
|
||||
export type ColumnsFlowDebug = {
|
||||
timestamp: string;
|
||||
fragments: Array<{
|
||||
pos: number;
|
||||
blocks: number;
|
||||
page: number;
|
||||
top: number;
|
||||
availablePx: number;
|
||||
contentPx: number;
|
||||
usedPerColumnPx: number;
|
||||
count: number;
|
||||
hasNext: boolean;
|
||||
openAtDocEnd: boolean;
|
||||
decorated: boolean;
|
||||
firstBlockNeedPx: number;
|
||||
overflow: boolean;
|
||||
join: null | { needPx: number; headIsJoinPrev: boolean; roomPx: number; wouldJoin: boolean };
|
||||
}>;
|
||||
};
|
||||
|
||||
const flowDebugAccessors = new WeakMap<EditorView, () => ColumnsFlowDebug | null>();
|
||||
|
||||
export function getColumnsFlowDebug(view: EditorView): ColumnsFlowDebug | null {
|
||||
return flowDebugAccessors.get(view)?.() ?? null;
|
||||
}
|
||||
|
||||
// A block's flowed height in document px: sum of its column fragments' rects
|
||||
// (a block split across columns yields one rect per column).
|
||||
function blockHeightPx(el: Element, scale: number): number {
|
||||
let h = 0;
|
||||
for (const r of Array.from(el.getClientRects())) h += r.height;
|
||||
return h / scale;
|
||||
}
|
||||
|
||||
// Total content height of a fragment's blocks as poured into one column flow:
|
||||
// block rects plus the collapsed inter-block margins (computed style, unscaled).
|
||||
function contentHeightPx(children: Element[], scale: number): number {
|
||||
let sum = 0;
|
||||
let prevMb = 0;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const cs = getComputedStyle(children[i]);
|
||||
const mt = parseFloat(cs.marginTop) || 0;
|
||||
const mb = parseFloat(cs.marginBottom) || 0;
|
||||
sum += blockHeightPx(children[i], scale);
|
||||
if (i > 0) sum += Math.max(prevMb, mt);
|
||||
prevMb = mb;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
// One rect per rendered text line of `el`, in document order (multicol renders
|
||||
// column 1's lines before column 2's, so a new line = a top jump in either
|
||||
// direction). Viewport coordinates.
|
||||
function lineRects(el: Element): { left: number; top: number; height: number }[] {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const lines: { left: number; top: number; height: number }[] = [];
|
||||
let lastTop = -Infinity;
|
||||
let textNode: Node | null;
|
||||
while ((textNode = walker.nextNode())) {
|
||||
if (!textNode.textContent) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(textNode);
|
||||
for (const r of Array.from(range.getClientRects())) {
|
||||
if (r.width <= 0 || r.height <= 0) continue;
|
||||
if (Math.abs(r.top - lastTop) > 2) {
|
||||
lines.push({ left: r.left, top: r.top, height: r.height });
|
||||
lastTop = r.top;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Median top-to-top line advance (viewport px); 0 when there are too few lines.
|
||||
function lineAdvance(lines: { top: number }[]): number {
|
||||
const gaps: number[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const g = lines[i].top - lines[i - 1].top;
|
||||
if (g > 2) gaps.push(g);
|
||||
}
|
||||
gaps.sort((a, b) => a - b);
|
||||
return gaps.length ? gaps[gaps.length >> 1] : 0;
|
||||
}
|
||||
|
||||
export const ColumnsFlow = Extension.create({
|
||||
name: 'columnsFlow',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
let rafId: number | null = null;
|
||||
let passes = 0;
|
||||
let decorations = DecorationSet.empty;
|
||||
let lastDecoKey = '';
|
||||
|
||||
const plugin = new Plugin<number>({
|
||||
key: flowKey,
|
||||
// Counts external changes (user edits, margin/orientation changes) so the
|
||||
// view hook can reset the pass budget; the plugin's own transactions don't.
|
||||
state: {
|
||||
init: () => 0,
|
||||
apply(tr, value) {
|
||||
if (tr.docChanged) decorations = decorations.map(tr.mapping, tr.doc);
|
||||
if ((tr.docChanged && !tr.getMeta(FLOW_TX)) || tr.getMeta(FORCE_PAGE_RECALC)) return value + 1;
|
||||
return value;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations() {
|
||||
return decorations;
|
||||
},
|
||||
},
|
||||
view(editorView) {
|
||||
function fragments(): Fragment[] {
|
||||
const out: Fragment[] = [];
|
||||
editorView.state.doc.forEach((node, offset) => {
|
||||
if (node.type.name === 'columns') out.push({ pos: offset, node });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Border-box top within .tiptap in document px (offsetTop chain — immune
|
||||
// to the .paper zoom transform, includes rendered spacers above).
|
||||
function topWithin(el: HTMLElement): number {
|
||||
let top = 0;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== editorView.dom) {
|
||||
top += node.offsetTop;
|
||||
node = node.offsetParent as HTMLElement | null;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
function getScale(): number {
|
||||
const r = editorView.dom.getBoundingClientRect();
|
||||
const h = editorView.dom.offsetHeight;
|
||||
return h ? r.height / h : 1;
|
||||
}
|
||||
|
||||
function dispatchFlow(tr: Transaction): void {
|
||||
editorView.dispatch(tr.setMeta('addToHistory', false).setMeta(FLOW_TX, true));
|
||||
}
|
||||
|
||||
function hasNextInChain(frags: Fragment[], i: number): boolean {
|
||||
const next = frags[i + 1];
|
||||
return !!next &&
|
||||
next.pos === frags[i].pos + frags[i].node.nodeSize &&
|
||||
sameColumnsAttrs(frags[i].node, next.node);
|
||||
}
|
||||
|
||||
// True when nothing but empty paragraphs follows the fragment — the chain
|
||||
// is "open" at the document end and fills sequentially while typing.
|
||||
function openAtDocEnd(frag: Fragment): boolean {
|
||||
const after = frag.pos + frag.node.nodeSize;
|
||||
let open = true;
|
||||
editorView.state.doc.forEach((n, off) => {
|
||||
if (off < after) return;
|
||||
if (n.type.name === 'columns') return; // continuation, judged via its own end
|
||||
if (!(n.type.name === 'paragraph' && n.content.size === 0)) open = false;
|
||||
});
|
||||
return open;
|
||||
}
|
||||
|
||||
// Split the paragraph at child `blockIndex` at the line whose prefix
|
||||
// consumes `budgetPx` of content flow (Word's mid-paragraph page break);
|
||||
// the second part is marked joinPrev so joins/export restore the original.
|
||||
// `force` takes at least one line even when the budget is tiny (progress
|
||||
// guard for a paragraph taller than the whole page slot).
|
||||
function splitParagraphInBlock(
|
||||
frag: Fragment, children: Element[], blockIndex: number, budgetPx: number,
|
||||
scale: number, force: boolean,
|
||||
): boolean {
|
||||
const block = frag.node.child(blockIndex);
|
||||
if (block.type.name !== 'paragraph') return false;
|
||||
const blockEl = children[blockIndex];
|
||||
if (!blockEl) return false;
|
||||
const lines = lineRects(blockEl);
|
||||
if (lines.length < 2) return false;
|
||||
const advance = (lineAdvance(lines) || DEFAULT_LINE_PX * scale) / scale;
|
||||
let budgetLines = Math.floor(budgetPx / advance);
|
||||
if (budgetLines < MIN_TAIL_LINES) {
|
||||
if (!force) return false;
|
||||
budgetLines = 1;
|
||||
}
|
||||
const prefixLines = Math.min(Math.max(budgetLines, 1), lines.length - 1);
|
||||
const target = lines[prefixLines];
|
||||
const coords = editorView.posAtCoords({ left: target.left + 1, top: target.top + target.height / 2 });
|
||||
if (!coords) return false;
|
||||
const pos = coords.pos;
|
||||
let blockStart = frag.pos + 1;
|
||||
for (let j = 0; j < blockIndex; j++) blockStart += frag.node.child(j).nodeSize;
|
||||
if (pos <= blockStart + 1 || pos >= blockStart + block.nodeSize - 1) return false;
|
||||
const typesAfter = [
|
||||
{ type: frag.node.type, attrs: frag.node.attrs },
|
||||
{ type: block.type, attrs: { ...block.attrs, joinPrev: true } },
|
||||
];
|
||||
if (!canSplit(editorView.state.doc, pos, 2, typesAfter)) return false;
|
||||
dispatchFlow(editorView.state.tr.split(pos, 2, typesAfter));
|
||||
return true;
|
||||
}
|
||||
|
||||
// One reflow step: fix the first out-of-sync boundary (merge a stray
|
||||
// joinPrev part, split an overflowing fragment, or pull a continuation
|
||||
// back) and dispatch. Convergence comes from re-running until quiescent.
|
||||
function reflow(): boolean {
|
||||
if (editorView.composing || !editorView.dom.isConnected) return false;
|
||||
const frags = fragments();
|
||||
if (!frags.length) return false;
|
||||
|
||||
const vm = readVerticalMargins(editorView.dom);
|
||||
const scale = getScale();
|
||||
|
||||
for (let i = 0; i < frags.length; i++) {
|
||||
const { pos, node } = frags[i];
|
||||
|
||||
// A joinPrev paragraph inside a fragment (left over after a fragment
|
||||
// join) merges into its predecessor right away.
|
||||
let childPos = pos + 1;
|
||||
for (let j = 0; j < node.childCount; j++) {
|
||||
const child = node.child(j);
|
||||
if (
|
||||
j > 0 && child.type.name === 'paragraph' && child.attrs.joinPrev &&
|
||||
node.child(j - 1).type.name === 'paragraph' &&
|
||||
canJoin(editorView.state.doc, childPos)
|
||||
) {
|
||||
dispatchFlow(editorView.state.tr.join(childPos));
|
||||
return true;
|
||||
}
|
||||
childPos += child.nodeSize;
|
||||
}
|
||||
|
||||
const el = editorView.nodeDOM(pos) as HTMLElement | null;
|
||||
if (!el || !el.classList.contains('columns-node')) continue;
|
||||
|
||||
const top = topWithin(el);
|
||||
const page = Math.floor(top / vm.cycle) + 1;
|
||||
const contentStart = (page - 1) * vm.cycle + vm.top;
|
||||
const contentEnd = contentStart + vm.contentHeight;
|
||||
// Mid-move (pageBreaks hasn't repositioned it yet) — measure next pass.
|
||||
if (top < contentStart - 0.5 || top >= contentEnd) continue;
|
||||
const available = contentEnd - top;
|
||||
const count = Math.max(1, node.attrs.count as number);
|
||||
const children = Array.from(el.children).filter(
|
||||
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
|
||||
);
|
||||
if (children.length !== node.childCount) continue;
|
||||
// The rendered box height is our own decoration, so overflow and room
|
||||
// are judged from the content itself (per-column share of the flow).
|
||||
const usedPerColumn = contentHeightPx(children, scale) / count;
|
||||
|
||||
if (usedPerColumn > available + 1) {
|
||||
// Overflow. Same first-block fit test (incl. margin) as pageBreaks'
|
||||
// push decision — the two must agree, or split/push/join cycle.
|
||||
const first = Math.max(blockHeightPx(children[0], scale) / count, MIN_BLOCK_PX);
|
||||
if (first + COLUMNS_FIT_MARGIN_PX > available) {
|
||||
// First block alone doesn't fit. Mid-page, pageBreaks pushes the
|
||||
// fragment; at a page top pushing can't help — split the paragraph
|
||||
// at a line boundary instead (Word's mid-paragraph page break).
|
||||
if (
|
||||
available >= vm.contentHeight - 1 &&
|
||||
splitParagraphInBlock(frags[i], children, 0, count * (available - SAFETY_PX), scale, true)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (node.childCount < 2) continue;
|
||||
let sum = 0;
|
||||
let k = 0;
|
||||
for (let j = 0; j < children.length - 1; j++) {
|
||||
const h = blockHeightPx(children[j], scale);
|
||||
if ((sum + h) / count > available - SAFETY_PX && j > 0) break;
|
||||
sum += h;
|
||||
k = j + 1;
|
||||
}
|
||||
// Leftover room after k whole blocks: fill it with the boundary
|
||||
// block's first lines (line split) rather than leaving it empty.
|
||||
const tailBudget = count * (available - SAFETY_PX) - sum;
|
||||
if (k < node.childCount && splitParagraphInBlock(frags[i], children, k, tailBudget, scale, false)) {
|
||||
return true;
|
||||
}
|
||||
let boundary = pos + 1;
|
||||
for (let j = 0; j < k; j++) boundary += node.child(j).nodeSize;
|
||||
const typesAfter = [{ type: node.type, attrs: node.attrs }];
|
||||
if (!canSplit(editorView.state.doc, boundary, 1, typesAfter)) continue;
|
||||
dispatchFlow(editorView.state.tr.split(boundary, 1, typesAfter));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fits: pull the adjacent continuation back when its head would
|
||||
// clearly fit too (the overflow branch then re-splits further down).
|
||||
if (hasNextInChain(frags, i)) {
|
||||
const next = frags[i + 1];
|
||||
const nextEl = editorView.nodeDOM(next.pos) as HTMLElement | null;
|
||||
const nextFirst = nextEl?.firstElementChild;
|
||||
if (!nextFirst) continue;
|
||||
const joinPos = pos + node.nodeSize;
|
||||
const headBlock = next.node.child(0);
|
||||
const headIsJoinPrev =
|
||||
headBlock.type.name === 'paragraph' && headBlock.attrs.joinPrev &&
|
||||
node.child(node.childCount - 1).type.name === 'paragraph';
|
||||
// A joinPrev head needs only one more line row to make progress; a
|
||||
// multi-line paragraph head can be pulled PARTIALLY (the overflow
|
||||
// split hands the excess back via a line split), so ~two line rows
|
||||
// of room justify the pull; any other block needs its full share.
|
||||
const headLines = headBlock.type.name === 'paragraph' ? lineRects(nextFirst) : [];
|
||||
const headAdvance = (lineAdvance(headLines) || DEFAULT_LINE_PX * scale) / scale;
|
||||
const need = headIsJoinPrev
|
||||
? Math.max(headAdvance, MIN_BLOCK_PX)
|
||||
: headLines.length >= 2
|
||||
? Math.max(MIN_TAIL_LINES * headAdvance, MIN_BLOCK_PX)
|
||||
: Math.max(blockHeightPx(nextFirst, scale) / count, MIN_BLOCK_PX);
|
||||
if (usedPerColumn + need <= available - HYSTERESIS_PX && canJoin(editorView.state.doc, joinPos)) {
|
||||
// Depth 2 rejoins the split paragraph in the same step.
|
||||
const tr = editorView.state.tr;
|
||||
try {
|
||||
tr.join(joinPos, headIsJoinPrev ? 2 : 1);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
dispatchFlow(tr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sequential-fill heights: a fragment with a continuation spans its page
|
||||
// slot; the open last fragment of a document-final chain hugs its content
|
||||
// (plus slack) so typing fills column 1 first. A chain followed by real
|
||||
// content keeps its last fragment undecorated (CSS balance).
|
||||
function updateDecorations(): void {
|
||||
const frags = fragments();
|
||||
const vm = readVerticalMargins(editorView.dom);
|
||||
const scale = getScale();
|
||||
const items: { from: number; to: number; height: number }[] = [];
|
||||
|
||||
for (let i = 0; i < frags.length; i++) {
|
||||
const { pos, node } = frags[i];
|
||||
const el = editorView.nodeDOM(pos) as HTMLElement | null;
|
||||
if (!el || !el.classList.contains('columns-node')) continue;
|
||||
const top = topWithin(el);
|
||||
const page = Math.floor(top / vm.cycle) + 1;
|
||||
const contentStart = (page - 1) * vm.cycle + vm.top;
|
||||
const contentEnd = contentStart + vm.contentHeight;
|
||||
if (top < contentStart - 0.5 || top >= contentEnd) continue;
|
||||
const available = contentEnd - top;
|
||||
const count = Math.max(1, node.attrs.count as number);
|
||||
const children = Array.from(el.children).filter(
|
||||
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
|
||||
);
|
||||
|
||||
let height: number;
|
||||
if (hasNextInChain(frags, i)) {
|
||||
height = available;
|
||||
} else if (openAtDocEnd(frags[i])) {
|
||||
// Full single-flow content height: while it's below the page slot,
|
||||
// everything pours into column 1 (column 2 only once page 1 is full).
|
||||
const contentH = contentHeightPx(children, scale);
|
||||
height = Math.min(available, contentH + OPEN_HEIGHT_SLACK_PX);
|
||||
} else {
|
||||
continue; // balanced end of a mid-document section
|
||||
}
|
||||
items.push({ from: pos, to: pos + node.nodeSize, height: Math.round(height) });
|
||||
}
|
||||
|
||||
const key = items.map((d) => `${d.from}:${d.to}:${d.height}`).join('|');
|
||||
if (key === lastDecoKey) return;
|
||||
lastDecoKey = key;
|
||||
decorations = items.length
|
||||
? DecorationSet.create(editorView.state.doc, items.map((d) =>
|
||||
Decoration.node(d.from, d.to, {
|
||||
style: `height:${d.height}px;column-fill:auto;overflow:hidden`,
|
||||
})))
|
||||
: DecorationSet.empty;
|
||||
// Re-render + let pageBreaks re-measure the new box heights.
|
||||
editorView.dispatch(
|
||||
editorView.state.tr.setMeta(FLOW_TX, true).setMeta(FORCE_PAGE_RECALC, true),
|
||||
);
|
||||
}
|
||||
|
||||
// Live analysis of every fragment (same math as reflow/updateDecorations),
|
||||
// exposed to the dev Debug dump via getColumnsFlowDebug.
|
||||
function buildDebug(): ColumnsFlowDebug | null {
|
||||
if (!editorView.dom.isConnected) return null;
|
||||
const frags = fragments();
|
||||
const vm = readVerticalMargins(editorView.dom);
|
||||
const scale = getScale();
|
||||
const out: ColumnsFlowDebug['fragments'] = [];
|
||||
for (let i = 0; i < frags.length; i++) {
|
||||
const { pos, node } = frags[i];
|
||||
const el = editorView.nodeDOM(pos) as HTMLElement | null;
|
||||
if (!el || !el.classList.contains('columns-node')) continue;
|
||||
const top = topWithin(el);
|
||||
const page = Math.floor(top / vm.cycle) + 1;
|
||||
const contentEnd = (page - 1) * vm.cycle + vm.top + vm.contentHeight;
|
||||
const available = contentEnd - top;
|
||||
const count = Math.max(1, node.attrs.count as number);
|
||||
const children = Array.from(el.children).filter(
|
||||
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
|
||||
);
|
||||
const contentPx = contentHeightPx(children, scale);
|
||||
const usedPerColumn = contentPx / count;
|
||||
const hasNext = hasNextInChain(frags, i);
|
||||
let join: ColumnsFlowDebug['fragments'][number]['join'] = null;
|
||||
if (hasNext) {
|
||||
const next = frags[i + 1];
|
||||
const nextEl = editorView.nodeDOM(next.pos) as HTMLElement | null;
|
||||
const nextFirst = nextEl?.firstElementChild;
|
||||
if (nextFirst) {
|
||||
const headBlock = next.node.child(0);
|
||||
const headIsJoinPrev =
|
||||
headBlock.type.name === 'paragraph' && headBlock.attrs.joinPrev &&
|
||||
node.child(node.childCount - 1).type.name === 'paragraph';
|
||||
const headLines = headBlock.type.name === 'paragraph' ? lineRects(nextFirst) : [];
|
||||
const headAdvance = (lineAdvance(headLines) || DEFAULT_LINE_PX * scale) / scale;
|
||||
const needPx = headIsJoinPrev
|
||||
? Math.max(headAdvance, MIN_BLOCK_PX)
|
||||
: headLines.length >= 2
|
||||
? Math.max(MIN_TAIL_LINES * headAdvance, MIN_BLOCK_PX)
|
||||
: Math.max(blockHeightPx(nextFirst, scale) / count, MIN_BLOCK_PX);
|
||||
const roomPx = available - HYSTERESIS_PX - usedPerColumn;
|
||||
join = { needPx: Math.round(needPx), headIsJoinPrev, roomPx: Math.round(roomPx), wouldJoin: usedPerColumn + needPx <= available - HYSTERESIS_PX };
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
pos,
|
||||
blocks: node.childCount,
|
||||
page,
|
||||
top: Math.round(top),
|
||||
availablePx: Math.round(available),
|
||||
contentPx: Math.round(contentPx),
|
||||
usedPerColumnPx: Math.round(usedPerColumn),
|
||||
count,
|
||||
hasNext,
|
||||
openAtDocEnd: openAtDocEnd(frags[i]),
|
||||
decorated: /column-fill:\s*auto/.test(el.style.cssText),
|
||||
firstBlockNeedPx: Math.round(children[0] ? Math.max(blockHeightPx(children[0], scale) / count, MIN_BLOCK_PX) : 0),
|
||||
overflow: usedPerColumn > available + 1,
|
||||
join,
|
||||
});
|
||||
}
|
||||
return { timestamp: new Date().toISOString(), fragments: out };
|
||||
}
|
||||
flowDebugAccessors.set(editorView, buildDebug);
|
||||
|
||||
function run() {
|
||||
rafId = null;
|
||||
if (passes < MAX_FLOW_PASSES && reflow()) {
|
||||
passes++;
|
||||
return; // geometry changes; decorations follow next pass
|
||||
}
|
||||
updateDecorations();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(run);
|
||||
}
|
||||
|
||||
schedule();
|
||||
|
||||
return {
|
||||
update(_view, prevState) {
|
||||
if (flowKey.getState(prevState) !== flowKey.getState(editorView.state)) {
|
||||
passes = 0; // fresh budget per external change
|
||||
}
|
||||
schedule();
|
||||
},
|
||||
destroy() {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
flowDebugAccessors.delete(editorView);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [plugin];
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
|
||||
import { COLUMNS_FIT_MARGIN_PX } from './columns';
|
||||
|
||||
export type PageBreakDebugSnapshot = {
|
||||
timestamp: string;
|
||||
@@ -120,7 +121,7 @@ const PAGE_GAP = 20;
|
||||
const DEFAULT_MARGIN_TOP = 96;
|
||||
const DEFAULT_MARGIN_BOTTOM = 96;
|
||||
|
||||
type VMargins = {
|
||||
export type VMargins = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
contentHeight: number;
|
||||
@@ -131,7 +132,8 @@ type VMargins = {
|
||||
// Reads vertical page margins + page height (px) from the --user-* props the Layout
|
||||
// panel writes. Document-px values, so they pair directly with offsetTop below. Side
|
||||
// margins don't affect pagination (only line wrapping), so they're ignored.
|
||||
function readVerticalMargins(dom: HTMLElement): VMargins {
|
||||
// Exported for columnsFlow.ts, which shares this geometry.
|
||||
export function readVerticalMargins(dom: HTMLElement): VMargins {
|
||||
const cs = getComputedStyle(dom);
|
||||
const top = parseFloat(cs.getPropertyValue('--user-margin-top'));
|
||||
const bottom = parseFloat(cs.getPropertyValue('--user-margin-bottom'));
|
||||
@@ -188,6 +190,11 @@ type Leaf = {
|
||||
cellStart?: boolean;
|
||||
rowStart?: boolean;
|
||||
rowEnd?: boolean;
|
||||
// A multi-column section fragment (columns.ts). columnsFlow.ts splits an
|
||||
// overflowing fragment at a block boundary, so pagination must NOT push it when
|
||||
// the flow can act: it only pushes when the first block alone can't fit the
|
||||
// remaining page space (then the flow splits it at the next page top).
|
||||
columnsFragment?: { blockCount: number; firstBlockNeededPx: number };
|
||||
};
|
||||
|
||||
const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
||||
@@ -513,7 +520,7 @@ export const PageBreaks = Extension.create({
|
||||
return { naturalLineTop, docPos: lo };
|
||||
}
|
||||
|
||||
function collectLeaves(contentHeight: number): Leaf[] {
|
||||
function collectLeaves(contentHeight: number, scaleFactor: number): Leaf[] {
|
||||
const dom = editorView.dom;
|
||||
const leaves: Leaf[] = [];
|
||||
// cumulativeSpacerHeight accumulates spacer offsetHeights (unscaled
|
||||
@@ -643,8 +650,8 @@ export const PageBreaks = Extension.create({
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A text box (textBox.ts) paginates atomically: its content lives inside a
|
||||
// rotated/floated node view where a line-split spacer can't render sanely.
|
||||
// A text box paginates atomically: a line-split spacer can't render
|
||||
// sanely inside a rotated/floated node view.
|
||||
if (child.classList.contains('textbox-node')) {
|
||||
leaves.push({
|
||||
el: child,
|
||||
@@ -655,6 +662,30 @@ export const PageBreaks = Extension.create({
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A multi-column section fragment: also atomic for spacer purposes (a
|
||||
// spacer inside a multicol container rebalances around it), but the
|
||||
// placement logic defers overflow to columnsFlow.ts via columnsFragment.
|
||||
if (child.classList.contains('columns-node')) {
|
||||
const count = Math.max(1, parseInt(child.dataset?.columns ?? '', 10) || 1);
|
||||
const first = child.firstElementChild as HTMLElement | null;
|
||||
let firstPx = 0;
|
||||
if (first) {
|
||||
for (const r of Array.from(first.getClientRects())) firstPx += r.height;
|
||||
firstPx = firstPx / scaleFactor / count;
|
||||
}
|
||||
leaves.push({
|
||||
el: child,
|
||||
kind: 'atomic',
|
||||
naturalTop: topWithin(child) - cumulativeSpacerHeight,
|
||||
naturalHeight: child.offsetHeight,
|
||||
inTableCell,
|
||||
columnsFragment: {
|
||||
blockCount: child.children.length,
|
||||
firstBlockNeededPx: Math.max(firstPx, 16),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A paragraph holding a floated/block image paginates atomically (pushed
|
||||
// whole, spacer placed before it): a line-split spacer inside it would sit
|
||||
// next to the float, where ProseMirror drops the widget.
|
||||
@@ -729,7 +760,7 @@ export const PageBreaks = Extension.create({
|
||||
const contentWidth = (Number.isFinite(pwRaw) ? pwRaw : 794) - marginLeft - (Number.isFinite(mrRaw) ? mrRaw : 80);
|
||||
|
||||
const scale = getScaleFactor();
|
||||
const leaves = collectLeaves(CONTENT_HEIGHT);
|
||||
const leaves = collectLeaves(CONTENT_HEIGHT, scale);
|
||||
|
||||
let cumulativeShift = 0;
|
||||
// Per-cell flow bracketing for a too-tall row (see Leaf flow markers): each
|
||||
@@ -783,6 +814,45 @@ export const PageBreaks = Extension.create({
|
||||
// Set when a leaf overflows but no spacer can bridge it (block > one page).
|
||||
let noPushReason: string | null = null;
|
||||
|
||||
// The empty trailing paragraph after a document-final columns chain: its
|
||||
// fragment box may run to the page bottom (sequential fill), which would
|
||||
// shove this invisible paragraph onto a phantom extra page. Leave it in
|
||||
// place and keep it out of the page count; typed text re-enables it.
|
||||
if (
|
||||
i === leaves.length - 1 &&
|
||||
leaf.el.tagName === 'P' &&
|
||||
!(leaf.el.textContent ?? '').length &&
|
||||
leaves[i - 1]?.columnsFragment
|
||||
) {
|
||||
leavesDebug.push({
|
||||
index: i,
|
||||
tag: leaf.el.tagName,
|
||||
kind: leaf.kind,
|
||||
inTableCell: !!leaf.inTableCell,
|
||||
tableRow: null,
|
||||
naturalTop: leaf.naturalTop,
|
||||
naturalHeight: leaf.naturalHeight,
|
||||
textPreview: '',
|
||||
intraSpacerHeights: [],
|
||||
effectiveTop,
|
||||
effectiveBottom,
|
||||
pageOfTop: page,
|
||||
pageOfBottom: getPageForY(effectiveBottom, CYCLE_PX),
|
||||
contentStart,
|
||||
contentEnd,
|
||||
overflowsPageEnd: effectiveBottom > contentEnd,
|
||||
});
|
||||
placementsDebug.push({
|
||||
leafIndex: i,
|
||||
docPos: null,
|
||||
height: 0,
|
||||
reason: 'trailing-empty-after-columns',
|
||||
spacerKind: 'none',
|
||||
columns: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// A manual page break forces the block to the next page's top (never the first
|
||||
// leaf, so no leading blank page); once settled at a page top the guard stops.
|
||||
// A forced block that then overflows is split by the normal logic next pass.
|
||||
@@ -819,7 +889,19 @@ export const PageBreaks = Extension.create({
|
||||
});
|
||||
} else if (effectiveBottom > contentEnd) {
|
||||
if (leaf.kind === 'atomic') {
|
||||
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
|
||||
const cf = leaf.columnsFragment;
|
||||
if (cf && cf.blockCount > 1 && cf.firstBlockNeededPx + COLUMNS_FIT_MARGIN_PX <= contentEnd - effectiveTop) {
|
||||
// columnsFlow.ts will split the fragment so a prefix fills this
|
||||
// page; pushing it whole here would fight that.
|
||||
noPushReason = 'columns-await-flow';
|
||||
} else if (cf && effectiveTop <= contentStart + 0.5) {
|
||||
// Already at a page top and still too tall (a paragraph taller
|
||||
// than the page slot): pushing just moves the problem one page
|
||||
// down forever — the flow line-splits the paragraph instead.
|
||||
noPushReason = 'columns-line-split-pending';
|
||||
} else if (leaf.naturalHeight <= CONTENT_HEIGHT || cf) {
|
||||
// A columns fragment may be pushed even when taller than a page:
|
||||
// the flow splits it again once it sits at the next page top.
|
||||
const target = pageContentStart(page + 1, vm.top, CYCLE_PX);
|
||||
const { docPos, row } = leafSpacer(leaf);
|
||||
breaks.push({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
const trailingNodeKey = new PluginKey('trailingNode');
|
||||
|
||||
// Block types that can't sit at the document end without trapping the cursor.
|
||||
const NEEDS_TRAILING_AFTER = new Set(['table', 'textBox']);
|
||||
const NEEDS_TRAILING_AFTER = new Set(['table', 'textBox', 'columns']);
|
||||
|
||||
export const TrailingNode = Extension.create({
|
||||
name: 'trailingNode',
|
||||
|
||||
+79
-17
@@ -3,7 +3,7 @@ import {
|
||||
TableOfContents,
|
||||
Table, TableRow, TableCell, Header, Footer, PageNumber,
|
||||
AlignmentType, HeadingLevel, LevelFormat, UnderlineType, BorderStyle, ShadingType,
|
||||
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType,
|
||||
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
|
||||
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
|
||||
TextWrappingType, TextWrappingSide,
|
||||
} from 'docx';
|
||||
@@ -21,7 +21,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
|
||||
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
|
||||
import { normalizeColor, HEADING_STYLE_OVERRIDES, mergeJoinedParagraphsJson, type HfExport } from './odt';
|
||||
|
||||
// DOCX export. Mirrors export/odt.ts feature-for-feature, but builds OOXML via the
|
||||
// `docx` library instead of odf-kit. Lazy-loaded from App.svelte so neither this
|
||||
@@ -732,6 +732,56 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
|
||||
return out;
|
||||
}
|
||||
|
||||
// One body section: a run of ordinary blocks (columns: null) or one columns node's
|
||||
// content. DOCX has no block-level column container — a mid-document multi-column
|
||||
// region is its own section, delimited by continuous section breaks.
|
||||
type BodyGroup = {
|
||||
columns: { count: number; gapCm: number } | null;
|
||||
children: (Paragraph | Table | TableOfContents)[];
|
||||
};
|
||||
|
||||
function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): BodyGroup[] {
|
||||
const groups: BodyGroup[] = [];
|
||||
let plain: TiptapNode[] = [];
|
||||
// Adjacent equal-attr fragments (a columnsFlow page-split chain) = one section;
|
||||
// their blocks are collected raw so page-split paragraphs merge before serializing.
|
||||
let cols: { count: number; gapCm: number; blocks: TiptapNode[] } | null = null;
|
||||
const flushPlain = () => {
|
||||
if (!plain.length) return;
|
||||
groups.push({ columns: null, children: blocksToDocx(plain, num, contentWidthCm, textBoxes) });
|
||||
plain = [];
|
||||
};
|
||||
const flushCols = () => {
|
||||
if (!cols) return;
|
||||
groups.push({
|
||||
columns: { count: cols.count, gapCm: cols.gapCm },
|
||||
children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, contentWidthCm, textBoxes),
|
||||
});
|
||||
cols = null;
|
||||
};
|
||||
for (const node of content) {
|
||||
if (node.type === 'columns') {
|
||||
const a = node.attrs ?? {};
|
||||
const count = typeof a.count === 'number' ? Math.min(3, Math.max(2, Math.round(a.count))) : 2;
|
||||
const gapCm = typeof a.gapCm === 'number' && Number.isFinite(a.gapCm) ? Math.min(5, Math.max(0, a.gapCm)) : 0.5;
|
||||
if (cols && cols.count === count && cols.gapCm === gapCm) {
|
||||
cols.blocks.push(...(node.content ?? []));
|
||||
} else {
|
||||
flushCols();
|
||||
flushPlain();
|
||||
cols = { count, gapCm, blocks: [...(node.content ?? [])] };
|
||||
}
|
||||
} else {
|
||||
flushCols();
|
||||
plain.push(node);
|
||||
}
|
||||
}
|
||||
flushCols();
|
||||
flushPlain();
|
||||
if (!groups.length) groups.push({ columns: null, children: [] });
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---- document styles -------------------------------------------------------
|
||||
function headingStyle(o: { fontSize: string; marginTop: string; marginBottom: string }) {
|
||||
return {
|
||||
@@ -768,8 +818,7 @@ export async function buildDocx(
|
||||
const contentWidthCm = pageWidthCm - margins.left - margins.right;
|
||||
|
||||
const textBoxes: TextBoxDocx[] = [];
|
||||
const body = blocksToDocx(docJson.content ?? [], num, contentWidthCm, textBoxes);
|
||||
if (body.length === 0) body.push(new Paragraph({}));
|
||||
const groups = bodyGroups(docJson.content ?? [], num, contentWidthCm, textBoxes);
|
||||
|
||||
// A TOC field is empty until its field is calculated; ask the reader to update fields
|
||||
// on open so Word/LibreOffice populate + hyperlink it (standard for TOC fields).
|
||||
@@ -782,27 +831,40 @@ export async function buildDocx(
|
||||
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
|
||||
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
|
||||
|
||||
// Identical page geometry on every sectPr (Word requires it per section). Fresh
|
||||
// Header/Footer instances per section so each sectPr — including the body-final
|
||||
// one our importer reads — carries its own references (Word's "Link to Previous").
|
||||
const pageProps = {
|
||||
size: { width: cmToTwip(pageWidthCm), height: cmToTwip(pageHeightCm), orientation: landscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
|
||||
margin: {
|
||||
top: cmToTwip(margins.top), bottom: cmToTwip(margins.bottom),
|
||||
left: cmToTwip(margins.left), right: cmToTwip(margins.right),
|
||||
header: cmToTwip(headerDist), footer: cmToTwip(footerDist),
|
||||
},
|
||||
};
|
||||
const mkHeaders = () => headerPara ? { default: new Header({ children: [paragraphToDocx(headerPara)] }) } : undefined;
|
||||
const mkFooters = () => footerPara ? { default: new Footer({ children: [paragraphToDocx(footerPara)] }) } : undefined;
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'Web ODF Editor',
|
||||
defaultTabStop: cmToTwip(1.25),
|
||||
...(hasToc ? { features: { updateFields: true } } : {}),
|
||||
styles: buildStyles(language),
|
||||
numbering: { config: num.config },
|
||||
sections: [{
|
||||
// A columns group is its own section; w:type=continuous describes the break
|
||||
// BEFORE a section, so it goes on every section but the first.
|
||||
sections: groups.map((g, i) => ({
|
||||
properties: {
|
||||
page: {
|
||||
size: { width: cmToTwip(pageWidthCm), height: cmToTwip(pageHeightCm), orientation: landscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
|
||||
margin: {
|
||||
top: cmToTwip(margins.top), bottom: cmToTwip(margins.bottom),
|
||||
left: cmToTwip(margins.left), right: cmToTwip(margins.right),
|
||||
header: cmToTwip(headerDist), footer: cmToTwip(footerDist),
|
||||
},
|
||||
},
|
||||
page: pageProps,
|
||||
...(i > 0 ? { type: SectionType.CONTINUOUS } : {}),
|
||||
...(g.columns
|
||||
? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } }
|
||||
: {}),
|
||||
},
|
||||
headers: headerPara ? { default: new Header({ children: [paragraphToDocx(headerPara)] }) } : undefined,
|
||||
footers: footerPara ? { default: new Footer({ children: [paragraphToDocx(footerPara)] }) } : undefined,
|
||||
children: body,
|
||||
}],
|
||||
headers: mkHeaders(),
|
||||
footers: mkFooters(),
|
||||
children: g.children.length ? g.children : [new Paragraph({})],
|
||||
})),
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
|
||||
+106
-4
@@ -83,6 +83,12 @@ const TOC_SENT = '';
|
||||
// <draw:frame>/<draw:text-box> or <draw:custom-shape>. U+E008.
|
||||
const TBX = '';
|
||||
|
||||
// Sentinel bracketing a hoisted multi-column section's blocks (marker paragraphs
|
||||
// COL S{i} COL … COL E{i} COL): replaceColumns hoists like replaceTextBoxes;
|
||||
// applyColumns wraps the region into a <text:section> with a minted section style
|
||||
// carrying <style:columns>. U+E009.
|
||||
const COL = '';
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -313,6 +319,64 @@ function replaceTextBoxes(doc: TiptapNode, boxes: TextBoxExport[]): TiptapNode {
|
||||
return { ...doc, content };
|
||||
}
|
||||
|
||||
// A multi-column section's geometry, collected by replaceColumns.
|
||||
type ColumnsExport = { count: number; gapCm: number };
|
||||
|
||||
function columnsDescriptor(node: TiptapNode): ColumnsExport {
|
||||
const a = node.attrs ?? {};
|
||||
const count = typeof a.count === 'number' ? Math.min(3, Math.max(2, Math.round(a.count))) : 2;
|
||||
const gapRaw = typeof a.gapCm === 'number' && Number.isFinite(a.gapCm) ? a.gapCm : 0.5;
|
||||
const clamped = Math.min(5, Math.max(0, gapRaw));
|
||||
return { count, gapCm: Math.round(clamped * 1000) / 1000 };
|
||||
}
|
||||
|
||||
// Re-merge paragraphs split by columnsFlow at a page boundary (the second part
|
||||
// carries the layout-internal joinPrev attr) — the file must hold ONE paragraph.
|
||||
// Also used by the DOCX exporter.
|
||||
export function mergeJoinedParagraphsJson(blocks: TiptapNode[]): TiptapNode[] {
|
||||
const out: TiptapNode[] = [];
|
||||
for (const b of blocks) {
|
||||
const prev = out[out.length - 1];
|
||||
if (b.type === 'paragraph' && b.attrs?.joinPrev && prev?.type === 'paragraph') {
|
||||
out[out.length - 1] = { ...prev, content: [...(prev.content ?? []), ...(b.content ?? [])] };
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Swap each top-level columns section for marker paragraphs bracketing its hoisted
|
||||
// child blocks (same mechanism as replaceTextBoxes); applyColumns re-wraps the
|
||||
// serialized region into a <text:section>. Adjacent equal-attr fragments are a
|
||||
// columnsFlow.ts page-split chain — one section, so they coalesce here.
|
||||
function replaceColumns(doc: TiptapNode, cols: ColumnsExport[]): TiptapNode {
|
||||
if (!doc.content?.length) return doc;
|
||||
const content: TiptapNode[] = [];
|
||||
const children = doc.content;
|
||||
for (let c = 0; c < children.length; c++) {
|
||||
const child = children[c];
|
||||
if (child.type === 'columns') {
|
||||
const i = cols.length;
|
||||
const desc = columnsDescriptor(child);
|
||||
cols.push(desc);
|
||||
const inner: TiptapNode[] = [...(child.content ?? [])];
|
||||
while (c + 1 < children.length && children[c + 1].type === 'columns') {
|
||||
const next = columnsDescriptor(children[c + 1]);
|
||||
if (next.count !== desc.count || next.gapCm !== desc.gapCm) break;
|
||||
inner.push(...(children[c + 1].content ?? []));
|
||||
c++;
|
||||
}
|
||||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${COL}S${i}${COL}` }] });
|
||||
content.push(...mergeJoinedParagraphsJson(inner));
|
||||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${COL}E${i}${COL}` }] });
|
||||
continue;
|
||||
}
|
||||
content.push(child);
|
||||
}
|
||||
return { ...doc, content };
|
||||
}
|
||||
|
||||
// One generated table of contents, collected by replaceTableOfContents and emitted by
|
||||
// applyToc. Entries are the cached heading→page rows (the node view keeps them current).
|
||||
type TocEntry = { text: string; level: number; page: number };
|
||||
@@ -1709,6 +1773,41 @@ function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Arra
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Section style for a multi-column region: balanced columns with a uniform gap.
|
||||
// text:dont-balance-text-columns sits on <style:columns> (not section-properties).
|
||||
function columnsSectionStyle(cols: ColumnsExport, index: number): string {
|
||||
return (
|
||||
`<style:style style:name="ColSec${index + 1}" style:family="section">` +
|
||||
`<style:section-properties style:editable="false">` +
|
||||
`<style:columns fo:column-count="${cols.count}" fo:column-gap="${cols.gapCm}cm"` +
|
||||
` text:dont-balance-text-columns="false"/>` +
|
||||
`</style:section-properties></style:style>`
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the columns marker paragraphs: wrap each S{i}…E{i} region (the section's
|
||||
// hoisted, fully serialized blocks) into a <text:section> — a block-level element,
|
||||
// so no anchor paragraph — and inject the minted section styles.
|
||||
function applyColumns(odtBytes: Uint8Array, cols: ColumnsExport[]): Uint8Array {
|
||||
if (!cols.length) return odtBytes;
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
let content = strFromU8(contentBytes);
|
||||
content = content.replace(
|
||||
new RegExp(`<text:p\\b[^>]*>${COL}S(\\d+)${COL}</text:p>([\\s\\S]*?)<text:p\\b[^>]*>${COL}E\\1${COL}</text:p>`, 'g'),
|
||||
(_m, idx: string, inner: string) => {
|
||||
const i = Number(idx);
|
||||
if (!cols[i]) return '';
|
||||
return `<text:section text:style-name="ColSec${i + 1}" text:name="ColumnsSection${i + 1}">${inner}</text:section>`;
|
||||
},
|
||||
);
|
||||
content = injectAutomaticStyles(content, cols.map((c, i) => columnsSectionStyle(c, i)).join(''));
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Minted automatic paragraph style for a TOC entry level: per-level left indent and a
|
||||
// right tab stop at the text width with a dotted leader (so the page number right-aligns
|
||||
// with dots, matching the on-screen TOC and Word/LibreOffice).
|
||||
@@ -1814,12 +1913,14 @@ export type HfExport = {
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
|
||||
// Collect embedded images and swap them for IMG sentinels before serialization;
|
||||
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
|
||||
// Text boxes are hoisted after replacePageBreaks (so PGB never lands on their
|
||||
// blocks) and before the inline passes (which then cover the hoisted blocks too).
|
||||
// Text boxes and columns sections are hoisted after replacePageBreaks (so PGB never
|
||||
// lands on their blocks) and before the inline passes (which then cover the hoisted
|
||||
// blocks too).
|
||||
const images: ImageExport[] = [];
|
||||
const tocs: TocExport[] = [];
|
||||
const textBoxes: TextBoxExport[] = [];
|
||||
const raw = replaceImages(replaceTabs(replaceHardBreaks(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes))), images);
|
||||
const columns: ColumnsExport[] = [];
|
||||
const raw = replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images);
|
||||
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||||
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||||
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
|
||||
@@ -1942,7 +2043,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const withBreaks = applyInlineSentinels(cleaned);
|
||||
const withImages = applyImages(withBreaks, images);
|
||||
const withTextBoxes = applyTextBoxes(withImages, textBoxes);
|
||||
const withToc = applyToc(withTextBoxes, tocs, contentWidthCm);
|
||||
const withColumns = applyColumns(withTextBoxes, columns);
|
||||
const withToc = applyToc(withColumns, tocs, contentWidthCm);
|
||||
const withPageBreaks = applyPageBreaks(withToc);
|
||||
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
|
||||
const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null);
|
||||
|
||||
@@ -119,6 +119,12 @@ const de: Messages = {
|
||||
imageNotInHf: 'Bilder sind in Kopf-/Fußzeilen nicht verfügbar',
|
||||
insertTextBox: 'Textfeld einfügen',
|
||||
textBoxNotInHf: 'Textfelder sind in Kopf-/Fußzeilen nicht verfügbar',
|
||||
columns: 'Spalten',
|
||||
columnsOne: 'Eine Spalte',
|
||||
columnsTwo: 'Zwei Spalten',
|
||||
columnsThree: 'Drei Spalten',
|
||||
columnGap: 'Spaltenabstand (cm)',
|
||||
columnsNotInHf: 'Spalten sind in Kopf-/Fußzeilen nicht verfügbar',
|
||||
insertToc: 'Inhaltsverzeichnis einfügen',
|
||||
tocNotInHf: 'Ein Inhaltsverzeichnis ist in Kopf-/Fußzeilen nicht verfügbar',
|
||||
insertLink: 'Link einfügen',
|
||||
|
||||
@@ -122,6 +122,12 @@ const en = {
|
||||
imageNotInHf: 'Images are not available in headers/footers',
|
||||
insertTextBox: 'Insert text box',
|
||||
textBoxNotInHf: 'Text boxes are not available in headers/footers',
|
||||
columns: 'Columns',
|
||||
columnsOne: 'One column',
|
||||
columnsTwo: 'Two columns',
|
||||
columnsThree: 'Three columns',
|
||||
columnGap: 'Column gap (cm)',
|
||||
columnsNotInHf: 'Columns are not available in headers/footers',
|
||||
insertToc: 'Insert table of contents',
|
||||
tocNotInHf: 'A table of contents is not available in headers/footers',
|
||||
insertLink: 'Insert link',
|
||||
|
||||
+88
-2
@@ -107,10 +107,30 @@ export function importDocx(bytes: Uint8Array): OdtImportResult {
|
||||
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
|
||||
if (!body) throw new Error('Not a Word document (no w:body).');
|
||||
|
||||
const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
|
||||
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
|
||||
// more than one column becomes a columns node (the trailing group is described
|
||||
// by the body-final sectPr, covering whole-document multi-column files).
|
||||
const { groups, midSectPrs } = splitBodySections(Array.from(body.children));
|
||||
const finalSectPr = fc(body, 'sectPr');
|
||||
const blocks: Node[] = [];
|
||||
for (const g of groups) {
|
||||
const inner = convertBlocks(g.els, ctx, 'body');
|
||||
const cols = sectPrColumns(g.sectPr ?? finalSectPr, ctx);
|
||||
if (cols) pushColumnRuns(inner, cols, blocks, ctx);
|
||||
else blocks.push(...inner);
|
||||
}
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
|
||||
const sect = parseSectPr(fc(body, 'sectPr'), ctx);
|
||||
let sect = parseSectPr(finalSectPr, ctx);
|
||||
// LibreOffice-produced multi-section files may reference headers/footers only on
|
||||
// an earlier section; page geometry still comes from the body-final sectPr.
|
||||
if (!sect.header && !sect.footer) {
|
||||
const prev = [...midSectPrs].reverse().find((s) => fc(s, 'headerReference') || fc(s, 'footerReference'));
|
||||
if (prev) {
|
||||
const prevSect = parseSectPr(prev, ctx);
|
||||
sect = { ...sect, header: prevSect.header, footer: prevSect.footer, headerDistCm: prevSect.headerDistCm, footerDistCm: prevSect.footerDistCm };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: { type: 'doc', content: blocks },
|
||||
@@ -838,6 +858,72 @@ function flattenTable(tbl: Element, ctx: Ctx): Node[] {
|
||||
}
|
||||
|
||||
// ---- section: page geometry + headers/footers ------------------------------
|
||||
// Split body children into sections: a <w:p> whose pPr carries a sectPr ends the
|
||||
// group before it (a continuous section break). The marker paragraph is dropped
|
||||
// unless it has run content (ours and Word's are empty). The trailing group has
|
||||
// sectPr null — the caller pairs it with the body-final <w:sectPr>.
|
||||
function splitBodySections(children: Element[]): {
|
||||
groups: { els: Element[]; sectPr: Element | null }[];
|
||||
midSectPrs: Element[];
|
||||
} {
|
||||
const groups: { els: Element[]; sectPr: Element | null }[] = [];
|
||||
const midSectPrs: Element[] = [];
|
||||
let els: Element[] = [];
|
||||
for (const el of children) {
|
||||
if (el.namespaceURI === W && el.localName === 'sectPr') continue; // body-final
|
||||
const sectPr = el.namespaceURI === W && el.localName === 'p' ? fc(fc(el, 'pPr'), 'sectPr') : null;
|
||||
if (sectPr) {
|
||||
if (fc(el, 'r') || fc(el, 'hyperlink')) els.push(el);
|
||||
groups.push({ els, sectPr });
|
||||
midSectPrs.push(sectPr);
|
||||
els = [];
|
||||
continue;
|
||||
}
|
||||
els.push(el);
|
||||
}
|
||||
groups.push({ els, sectPr: null });
|
||||
return { groups, midSectPrs };
|
||||
}
|
||||
|
||||
// A sectPr's w:cols → columns attrs when it declares more than one column.
|
||||
function sectPrColumns(sectPr: Element | null, ctx: Ctx): { count: number; gapCm: number } | null {
|
||||
const cols = fc(sectPr, 'cols');
|
||||
if (!cols) return null;
|
||||
const colEls = fcAll(cols, 'col');
|
||||
const num = intAttr(cols, W, 'num') ?? (colEls.length > 1 ? colEls.length : null);
|
||||
if (!num || num <= 1) return null;
|
||||
let count = num;
|
||||
if (count > 3) {
|
||||
ctx.warnings.add('Sections with more than 3 columns were reduced to 3 columns');
|
||||
count = 3;
|
||||
}
|
||||
const space = intAttr(cols, W, 'space') ?? (colEls[0] ? intAttr(colEls[0], W, 'space') : null) ?? 283;
|
||||
return { count, gapCm: Math.min(5, Math.max(0, round2(twipToCm(space)))) };
|
||||
}
|
||||
|
||||
// Block types a columns node can contain (mirrors import/odt.ts).
|
||||
const COLUMNS_ALLOWED = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
|
||||
|
||||
// Wrap a multi-column section's converted blocks into columns nodes: maximal runs of
|
||||
// allowed types become one node each; anything else is emitted between them.
|
||||
function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, out: Node[], ctx: Ctx): void {
|
||||
let run: Node[] = [];
|
||||
const flush = () => {
|
||||
if (run.length) out.push({ type: 'columns', attrs: { count: cols.count, gapCm: cols.gapCm }, content: run });
|
||||
run = [];
|
||||
};
|
||||
for (const block of inner) {
|
||||
if (COLUMNS_ALLOWED.has(block.type)) {
|
||||
run.push(block);
|
||||
} else {
|
||||
ctx.warnings.add('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns');
|
||||
flush();
|
||||
out.push(block);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
function parseSectPr(sect: Element | null, ctx: Ctx): {
|
||||
margins: PageMargins | null; orientation: Orientation | null;
|
||||
header: HfDoc; footer: HfDoc; headerDistCm: number | null; footerDistCm: number | null;
|
||||
|
||||
+33
-1
@@ -369,6 +369,35 @@ function parseXml(xml: string): Document {
|
||||
|
||||
// ---- block conversion -----------------------------------------------------------
|
||||
|
||||
// Block types a columns section (columns.ts) can contain.
|
||||
const COLUMNS_ALLOWED = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
|
||||
|
||||
// Wrap a multi-column <text:section>'s converted blocks into columns nodes: maximal
|
||||
// runs of allowed types become one node each; anything else (tables, text boxes,
|
||||
// nested sections) is emitted between the runs at top level with a warning.
|
||||
function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, out: Node[], ctx: Ctx): void {
|
||||
let count = cols.count;
|
||||
if (count > 3) {
|
||||
ctx.warnings.add('Sections with more than 3 columns were reduced to 3 columns');
|
||||
count = 3;
|
||||
}
|
||||
let run: Node[] = [];
|
||||
const flush = () => {
|
||||
if (run.length) out.push({ type: 'columns', attrs: { count, gapCm: cols.gapCm }, content: run });
|
||||
run = [];
|
||||
};
|
||||
for (const block of inner) {
|
||||
if (COLUMNS_ALLOWED.has(block.type)) {
|
||||
run.push(block);
|
||||
} else {
|
||||
ctx.warnings.add('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns');
|
||||
flush();
|
||||
out.push(block);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
|
||||
const out: Node[] = [];
|
||||
|
||||
@@ -398,7 +427,10 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
if (list) out.push(list);
|
||||
pushWithPending(null);
|
||||
} else if (el.localName === 'section') {
|
||||
out.push(...convertBlocks(Array.from(el.children), ctx, kind, boldByDefault));
|
||||
const inner = convertBlocks(Array.from(el.children), ctx, kind, boldByDefault);
|
||||
const cols = ctx.resolver.sectionColumns(el.getAttributeNS(NS.text, 'style-name'));
|
||||
if (kind === 'body' && cols) pushColumnRuns(inner, cols, out, ctx);
|
||||
else out.push(...inner);
|
||||
} else if (el.localName === 'table-of-content' && kind === 'body') {
|
||||
// Generated table of contents → a tableOfContents node (regenerated live).
|
||||
out.push(convertToc(el));
|
||||
|
||||
@@ -101,6 +101,9 @@ export class StyleResolver {
|
||||
private defaults = new Map<string, StyleEntry>(); // family → style:default-style
|
||||
private fontFaces = new Map<string, string>(); // style:name → first font-family
|
||||
private listStyles = new Map<string, Element>(); // style:name → text:list-style
|
||||
// Section styles kept as raw elements: <style:columns> is a child element of
|
||||
// <style:section-properties>, which the attribute-only StyleEntry drops.
|
||||
private sectionStyleEls = new Map<string, Element>();
|
||||
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
|
||||
private stylesDoc: Document | null;
|
||||
|
||||
@@ -140,6 +143,7 @@ export class StyleResolver {
|
||||
const name = el.getAttributeNS(NS.style, 'name');
|
||||
const family = el.getAttributeNS(NS.style, 'family');
|
||||
if (name && family) this.styles.set(`${family}\0${name}`, entryFromStyleElement(el));
|
||||
if (name && family === 'section') this.sectionStyleEls.set(name, el);
|
||||
} else if (el.namespaceURI === NS.style && el.localName === 'default-style') {
|
||||
const family = el.getAttributeNS(NS.style, 'family');
|
||||
if (family) this.defaults.set(family, entryFromStyleElement(el));
|
||||
@@ -267,6 +271,30 @@ export class StyleResolver {
|
||||
return name ? this.listStyles.get(name) ?? null : null;
|
||||
}
|
||||
|
||||
// A section style's column layout: { count, gapCm } when it declares more than one
|
||||
// column, else null. The gap falls back to the per-column indent form (first
|
||||
// column's fo:end-indent + second's fo:start-indent) LibreOffice sometimes writes.
|
||||
sectionColumns(name: string | null): { count: number; gapCm: number } | null {
|
||||
const el = name ? this.sectionStyleEls.get(name) : null;
|
||||
if (!el) return null;
|
||||
const props = el.getElementsByTagNameNS(NS.style, 'section-properties')[0];
|
||||
const cols = props?.getElementsByTagNameNS(NS.style, 'columns')[0];
|
||||
if (!cols) return null;
|
||||
const count = parseInt(cols.getAttributeNS(NS.fo, 'column-count') ?? '', 10);
|
||||
if (!Number.isFinite(count) || count <= 1) return null;
|
||||
let gap = lengthToCm(cols.getAttributeNS(NS.fo, 'column-gap'));
|
||||
if (gap == null) {
|
||||
const colEls = Array.from(cols.getElementsByTagNameNS(NS.style, 'column'));
|
||||
if (colEls.length >= 2) {
|
||||
const end = lengthToCm(colEls[0].getAttributeNS(NS.fo, 'end-indent')) ?? 0;
|
||||
const start = lengthToCm(colEls[1].getAttributeNS(NS.fo, 'start-indent')) ?? 0;
|
||||
if (end + start > 0) gap = end + start;
|
||||
}
|
||||
}
|
||||
const gapCm = Math.min(5, Math.max(0, gap ?? 0.5));
|
||||
return { count, gapCm: Math.round(gapCm * 100) / 100 };
|
||||
}
|
||||
|
||||
// The master page governing the document (prefer "Standard", else the first).
|
||||
private masterPageEl(): Element | null {
|
||||
const doc = this.stylesDoc;
|
||||
|
||||
@@ -343,6 +343,19 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Multi-column section (columns.ts); column-count/gap come from the node's
|
||||
inline style. Balanced fill matches ODF/Word section behavior; no visual
|
||||
affordance — the section must read as plain flowing text. */
|
||||
.paper .tiptap .columns-node {
|
||||
column-fill: balance;
|
||||
}
|
||||
.paper .tiptap .columns-node > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.paper .tiptap .columns-node > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
|
||||
.paper .tiptap {
|
||||
tab-size: 1.25cm;
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('DOCX export → import round trip', () => {
|
||||
para([text('bold in box', [{ type: 'bold' }])]),
|
||||
] },
|
||||
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
|
||||
{ type: 'columns', attrs: { count: 2, gapCm: 0.8 }, content: [para('newspaper one'), para('newspaper two')] },
|
||||
{ type: 'table', content: [
|
||||
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
|
||||
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })] },
|
||||
@@ -70,13 +71,82 @@ describe('DOCX export → import round trip', () => {
|
||||
|
||||
let doc: N;
|
||||
let result: any;
|
||||
let documentXml = '';
|
||||
it('imports without throwing', async () => {
|
||||
const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait', hf, { language: 'en', country: 'US' });
|
||||
documentXml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||||
result = importDocx(bytes);
|
||||
doc = result.content;
|
||||
expect(doc.type).toBe('doc');
|
||||
});
|
||||
|
||||
it('emits the columns region as its own continuous section with w:cols', () => {
|
||||
// Three sections: before / columns / after (each earlier sectPr rides an empty
|
||||
// paragraph's pPr; the last is the body-final one).
|
||||
expect(documentXml.match(/<w:sectPr/g)!.length).toBe(3);
|
||||
expect(documentXml).toMatch(/<w:cols[^>]*w:num="2"/);
|
||||
expect(documentXml).toMatch(/<w:cols[^>]*w:space="454"/); // 0.8cm in twips
|
||||
expect(documentXml.match(/<w:type w:val="continuous"\/>/g)!.length).toBe(2);
|
||||
// Word wants the page geometry repeated per sectPr.
|
||||
expect(documentXml.match(/<w:pgSz/g)!.length).toBe(3);
|
||||
expect(documentXml.match(/<w:pgMar/g)!.length).toBe(3);
|
||||
// The body-final sectPr (the one the importer reads) still references the header.
|
||||
const finalSect = documentXml.slice(documentXml.lastIndexOf('<w:sectPr'));
|
||||
expect(finalSect).toContain('<w:headerReference');
|
||||
});
|
||||
|
||||
it('coalesces adjacent equal-attr fragments (columnsFlow page splits) into one section', async () => {
|
||||
const fragmented = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
para('lead'),
|
||||
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('frag one a'), para('frag one b')] },
|
||||
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('frag two')] },
|
||||
{ type: 'columns', attrs: { count: 3, gapCm: 0.5 }, content: [para('other section')] },
|
||||
para('tail'),
|
||||
],
|
||||
} as any;
|
||||
const bytes = await buildDocx(fragmented);
|
||||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||||
expect(xml.match(/<w:sectPr/g)!.length).toBe(4); // lead / merged 2-col / 3-col / tail
|
||||
const res = importDocx(bytes).content as N;
|
||||
const cols = walk(res, 'columns');
|
||||
expect(cols.map((c) => c.content!.length)).toEqual([3, 1]);
|
||||
expect(cols[0].attrs.count).toBe(2);
|
||||
expect(cols[1].attrs.count).toBe(3);
|
||||
});
|
||||
|
||||
it('re-merges a page-boundary line-split paragraph (joinPrev) on export', async () => {
|
||||
const split = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('first half ')] },
|
||||
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [
|
||||
{ type: 'paragraph', attrs: { joinPrev: true }, content: [text('second half.')] },
|
||||
para('tail'),
|
||||
] },
|
||||
],
|
||||
} as any;
|
||||
const bytes = await buildDocx(split);
|
||||
const res = importDocx(bytes).content as N;
|
||||
const cols = walk(res, 'columns');
|
||||
expect(cols.length).toBe(1);
|
||||
expect(cols[0].content!.length).toBe(2);
|
||||
expect(walk(cols[0].content![0], 'text').map((t) => t.text).join('')).toBe('first half second half.');
|
||||
});
|
||||
|
||||
it('round-trips the columns section (count + gap + content, marker dropped)', () => {
|
||||
const cols = walk(doc, 'columns');
|
||||
expect(cols.length).toBe(1);
|
||||
expect(cols[0].attrs.count).toBe(2);
|
||||
expect(cols[0].attrs.gapCm).toBe(0.8);
|
||||
const texts = walk(cols[0], 'text').map((t) => t.text);
|
||||
expect(texts).toEqual(['newspaper one', 'newspaper two']);
|
||||
// The empty section-break marker paragraphs must not leak into the document.
|
||||
const paras = walk(doc, 'paragraph');
|
||||
expect(paras.filter((p) => !p.content && !p.attrs?.fontSize).length).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips headings + paragraph block attrs', () => {
|
||||
const h = doc.content![0];
|
||||
expect(h.type).toBe('heading');
|
||||
@@ -516,3 +586,78 @@ describe('DOCX table of contents (TOC field) round trip', () => {
|
||||
expect(headings.map(h => walk(h, 'text')[0]?.text)).toEqual(['Alpha', 'Beta']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOCX import of foreign multi-column sections', () => {
|
||||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||||
|
||||
it('wraps a mid-body continuous section with w:cols and drops the empty marker paragraph', () => {
|
||||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
<w:p><w:r><w:t>intro text</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:sectPr><w:type w:val="continuous"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr></w:pPr></w:p>
|
||||
<w:p><w:r><w:t>col one</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>col two</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:sectPr><w:type w:val="continuous"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/><w:cols w:num="2" w:space="708"/></w:sectPr></w:pPr></w:p>
|
||||
<w:p><w:r><w:t>outro text</w:t></w:r></w:p>
|
||||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
|
||||
</w:body></w:document>`;
|
||||
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
|
||||
const doc = result.content as N;
|
||||
|
||||
const cols = walk(doc, 'columns');
|
||||
expect(cols.length).toBe(1);
|
||||
expect(cols[0].attrs.count).toBe(2);
|
||||
expect(cols[0].attrs.gapCm).toBe(1.25); // 708 twips
|
||||
expect(walk(cols[0], 'text').map((t) => t.text)).toEqual(['col one', 'col two']);
|
||||
// A sectPr paragraph closes the group before it: intro (single-col) / columns /
|
||||
// trailing outro on the body-final sectPr. Empty markers are dropped.
|
||||
const top = doc.content!.map((n) => n.type);
|
||||
expect(top).toEqual(['paragraph', 'columns', 'paragraph']);
|
||||
expect(walk(doc.content![0], 'text')[0].text).toBe('intro text');
|
||||
expect(walk(doc.content![2], 'text')[0].text).toBe('outro text');
|
||||
});
|
||||
|
||||
it('keeps a text-bearing sectPr paragraph and clamps >3 columns', () => {
|
||||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
<w:p><w:r><w:t>kept marker text</w:t></w:r><w:pPr><w:sectPr><w:type w:val="continuous"/><w:cols w:num="4" w:space="200"/></w:sectPr></w:pPr></w:p>
|
||||
<w:p><w:r><w:t>tail</w:t></w:r></w:p>
|
||||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr>
|
||||
</w:body></w:document>`;
|
||||
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
|
||||
const doc = result.content as N;
|
||||
const cols = walk(doc, 'columns');
|
||||
expect(cols.length).toBe(1);
|
||||
expect(cols[0].attrs.count).toBe(3);
|
||||
expect(walk(cols[0], 'text')[0].text).toBe('kept marker text');
|
||||
expect(result.warnings).toContain('Sections with more than 3 columns were reduced to 3 columns');
|
||||
});
|
||||
|
||||
it('wraps a whole-document multi-column file (cols on the body-final sectPr only)', () => {
|
||||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
<w:p><w:r><w:t>all of it</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>flows in columns</w:t></w:r></w:p>
|
||||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:cols w:num="3" w:space="425"/></w:sectPr>
|
||||
</w:body></w:document>`;
|
||||
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
|
||||
const doc = result.content as N;
|
||||
const cols = walk(doc, 'columns');
|
||||
expect(cols.length).toBe(1);
|
||||
expect(cols[0].attrs.count).toBe(3);
|
||||
expect(cols[0].attrs.gapCm).toBe(0.75); // 425 twips
|
||||
expect(walk(cols[0], 'text').map((t) => t.text)).toEqual(['all of it', 'flows in columns']);
|
||||
});
|
||||
|
||||
it('splits a columned section around a table with a warning', () => {
|
||||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
<w:p><w:r><w:t>before table</w:t></w:r></w:p>
|
||||
<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>
|
||||
<w:tr><w:tc><w:p><w:r><w:t>in table</w:t></w:r></w:p></w:tc></w:tr>
|
||||
</w:tbl>
|
||||
<w:p><w:r><w:t>after table</w:t></w:r></w:p>
|
||||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:cols w:num="2" w:space="283"/></w:sectPr>
|
||||
</w:body></w:document>`;
|
||||
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
|
||||
const doc = result.content as N;
|
||||
expect(doc.content!.map((n) => n.type)).toEqual(['columns', 'table', 'columns']);
|
||||
expect(result.warnings).toContain('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns');
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ const IMGN = (width: number, height: number, alt?: string, rotation?: number, wr
|
||||
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
|
||||
} });
|
||||
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
|
||||
const COLS = (attrs: N, ...content: N[]): N => ({ type: 'columns', attrs, content });
|
||||
|
||||
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
|
||||
|
||||
@@ -76,6 +77,7 @@ const fixture: N = {
|
||||
P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')),
|
||||
TBX({ width: 288, height: 96 }, P(null, T('box para one')), P(null, T('box '), T('bold', { type: 'bold' }))),
|
||||
TBX({ width: 192, height: 80, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
|
||||
COLS({ count: 2, gapCm: 0.5 }, P(null, T('newspaper column text one')), P(null, T('newspaper column text two'))),
|
||||
{ type: 'bulletList', content: [
|
||||
LI(P(null, T('bullet one'))),
|
||||
LI(P(null, T('bullet two')), { type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [
|
||||
@@ -751,6 +753,179 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 8: multi-column sections (ODT)', () => {
|
||||
const colsDoc: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
P(null, T('before')),
|
||||
COLS({ count: 2, gapCm: 0.8 },
|
||||
P(null, T('col text with '), T('bold', { type: 'bold' })),
|
||||
{ type: 'bulletList', content: [
|
||||
LI(P(null, T('col bullet one'))),
|
||||
LI(P(null, T('col bullet two'))),
|
||||
] },
|
||||
H({ level: 3 }, T('Col heading')),
|
||||
),
|
||||
P(null, T('between')),
|
||||
COLS({ count: 3, gapCm: 0.5 }, P(null, T('three column text'))),
|
||||
P(null, T('after')),
|
||||
],
|
||||
};
|
||||
|
||||
it('emits <text:section> with a section style and round-trips attrs + content', async () => {
|
||||
const bytes = await buildOdt(colsDoc, margins, 'portrait');
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
check('emits <text:section> with the minted style', /<text:section text:style-name="ColSec1"/.test(xml));
|
||||
check('mints a section-family style', xml.includes('style:family="section"'));
|
||||
check('2-col style carries count + gap', xml.includes('fo:column-count="2"') && xml.includes('fo:column-gap="0.8cm"'));
|
||||
check('3-col style carries count + default gap', xml.includes('fo:column-count="3"') && xml.includes('fo:column-gap="0.5cm"'));
|
||||
check('columns balance (dont-balance false)', xml.includes('text:dont-balance-text-columns="false"'));
|
||||
check('section content is real block markup', /<text:section[^>]*>[\s\S]*?<text:list/.test(xml));
|
||||
check('no leftover COL sentinel', !xml.includes(''));
|
||||
|
||||
const res = importOdt(bytes);
|
||||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||||
const cols = (res.content.content ?? []).filter((n: N) => n.type === 'columns');
|
||||
check('both sections round-trip', cols.length === 2, (res.content.content ?? []).map((n: N) => n.type));
|
||||
const [two, three] = cols;
|
||||
check('2-col attrs exact', two?.attrs?.count === 2 && two?.attrs?.gapCm === 0.8, two?.attrs);
|
||||
check('3-col attrs exact', three?.attrs?.count === 3 && three?.attrs?.gapCm === 0.5, three?.attrs);
|
||||
check('marks + list + heading survive inside the section',
|
||||
two?.content?.[0]?.content?.some((n: N) => n.marks?.some((m: N) => m.type === 'bold')) &&
|
||||
two?.content?.[1]?.type === 'bulletList' && two?.content?.[2]?.type === 'heading', two?.content);
|
||||
check('document JSON round-trips', firstDiff(normalize(colsDoc), normalize(res.content)) === null,
|
||||
firstDiff(normalize(colsDoc), normalize(res.content)));
|
||||
});
|
||||
|
||||
it('coalesces adjacent equal-attr fragments (columnsFlow page splits) into one section', async () => {
|
||||
const fragmented: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
COLS({ count: 2, gapCm: 0.5 }, P(null, T('frag one a')), P(null, T('frag one b'))),
|
||||
COLS({ count: 2, gapCm: 0.5 }, P(null, T('frag two'))),
|
||||
COLS({ count: 3, gapCm: 0.5 }, P(null, T('other section'))),
|
||||
P(null, T('tail')),
|
||||
],
|
||||
};
|
||||
const bytes = await buildOdt(fragmented, margins, 'portrait');
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
check('two sections emitted (chain merged, 3-col separate)',
|
||||
(xml.match(/<text:section /g) ?? []).length === 2, (xml.match(/<text:section /g) ?? []).length);
|
||||
|
||||
const res = importOdt(bytes);
|
||||
const cols = (res.content.content ?? []).filter((n: N) => n.type === 'columns');
|
||||
check('chain reimports as one node with all three blocks',
|
||||
cols.length === 2 && cols[0]?.content?.length === 3 && cols[1]?.content?.length === 1,
|
||||
cols.map((n: N) => n.content?.length));
|
||||
});
|
||||
|
||||
it('re-merges a page-boundary line-split paragraph (joinPrev) on export', async () => {
|
||||
const split: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
COLS({ count: 2, gapCm: 0.5 },
|
||||
P(null, T('intact lead. ')),
|
||||
P(null, T('first half of the long paragraph ')),
|
||||
),
|
||||
COLS({ count: 2, gapCm: 0.5 },
|
||||
P({ joinPrev: true }, T('second half of it.')),
|
||||
P(null, T('intact tail.')),
|
||||
),
|
||||
P(null, T('after')),
|
||||
],
|
||||
};
|
||||
const bytes = await buildOdt(split, margins, 'portrait');
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
check('split paragraph exported as ONE text:p',
|
||||
/first half of the long paragraph second half of it\./.test(xml.replace(/<[^>]+>/g, '')), null);
|
||||
check('one section, three paragraphs inside',
|
||||
(xml.match(/<text:section /g) ?? []).length === 1, null);
|
||||
|
||||
const res = importOdt(bytes);
|
||||
const col = (res.content.content ?? []).find((n: N) => n.type === 'columns');
|
||||
check('reimports as one section with three paragraphs', col?.content?.length === 3, col?.content?.length);
|
||||
const texts = (col?.content ?? []).map((p: N) => (p.content ?? []).map((t: N) => t.text).join(''));
|
||||
check('merged paragraph text intact',
|
||||
texts[1] === 'first half of the long paragraph second half of it.', texts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 9: foreign multi-column sections → importOdt', () => {
|
||||
it('derives the gap from column indents, moves tables out, clamps counts, splices style-less sections', () => {
|
||||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0">
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="Sect1" style:family="section">
|
||||
<style:section-properties style:editable="false">
|
||||
<style:columns fo:column-count="2">
|
||||
<style:column style:rel-width="4818*" fo:start-indent="0cm" fo:end-indent="0.25cm"/>
|
||||
<style:column style:rel-width="4818*" fo:start-indent="0.25cm" fo:end-indent="0cm"/>
|
||||
</style:columns>
|
||||
</style:section-properties>
|
||||
</style:style>
|
||||
<style:style style:name="Sect2" style:family="section">
|
||||
<style:section-properties>
|
||||
<style:columns fo:column-count="2" fo:column-gap="1cm"/>
|
||||
</style:section-properties>
|
||||
</style:style>
|
||||
<style:style style:name="Sect3" style:family="section">
|
||||
<style:section-properties>
|
||||
<style:columns fo:column-count="4" fo:column-gap="0.3cm"/>
|
||||
</style:section-properties>
|
||||
</style:style>
|
||||
</office:automatic-styles>
|
||||
<office:body><office:text>
|
||||
<text:section text:style-name="Sect1" text:name="S1">
|
||||
<text:p>lo col one</text:p>
|
||||
<text:p>lo col two</text:p>
|
||||
</text:section>
|
||||
<text:section text:style-name="Sect2" text:name="S2">
|
||||
<text:p>before table</text:p>
|
||||
<table:table>
|
||||
<table:table-column/>
|
||||
<table:table-row><table:table-cell><text:p>in table</text:p></table:table-cell></table:table-row>
|
||||
</table:table>
|
||||
<text:p>after table</text:p>
|
||||
</text:section>
|
||||
<text:section text:style-name="Sect3" text:name="S3">
|
||||
<text:p>four cols</text:p>
|
||||
</text:section>
|
||||
<text:section text:name="S4">
|
||||
<text:p>plain section text</text:p>
|
||||
</text:section>
|
||||
</office:text></office:body>
|
||||
</office:document-content>`;
|
||||
const foreign = zipSync({
|
||||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||||
'content.xml': [strToU8(contentXml), { level: 6 }],
|
||||
} as any);
|
||||
|
||||
const f = importOdt(foreign);
|
||||
const c = f.content.content!;
|
||||
const cols = c.filter((n: N) => n.type === 'columns');
|
||||
check('4 columns nodes (S1, S2 split around the table, S3)', cols.length === 4, c.map((n: N) => n.type));
|
||||
|
||||
const s1 = cols[0];
|
||||
check('S1: gap derived from column indents (0.25+0.25)', s1?.attrs?.count === 2 && s1?.attrs?.gapCm === 0.5, s1?.attrs);
|
||||
check('S1: both paragraphs inside', s1?.content?.length === 2 && s1?.content?.[0]?.content?.[0]?.text === 'lo col one', s1?.content);
|
||||
|
||||
const s2idx = c.findIndex((n: N) => n.type === 'columns' && n.content?.[0]?.content?.[0]?.text === 'before table');
|
||||
check('S2: table moved out between two columns nodes',
|
||||
c[s2idx]?.attrs?.gapCm === 1 && c[s2idx + 1]?.type === 'table' &&
|
||||
c[s2idx + 2]?.type === 'columns' && c[s2idx + 2]?.content?.[0]?.content?.[0]?.text === 'after table',
|
||||
c.slice(s2idx, s2idx + 3).map((n: N) => n.type));
|
||||
check('S2: move-out warning reported',
|
||||
f.warnings.includes('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns'), f.warnings);
|
||||
|
||||
const s3 = cols[cols.length - 1];
|
||||
check('S3: count clamped to 3', s3?.attrs?.count === 3 && s3?.attrs?.gapCm === 0.3, s3?.attrs);
|
||||
check('S3: clamp warning reported', f.warnings.includes('Sections with more than 3 columns were reduced to 3 columns'), f.warnings);
|
||||
|
||||
check('style-less section spliced to plain paragraphs',
|
||||
c.some((n: N) => n.type === 'paragraph' && n.content?.[0]?.text === 'plain section text'), c.map((n: N) => n.type));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 5: table of contents (text:table-of-content)', () => {
|
||||
const tocDoc: N = {
|
||||
type: 'doc',
|
||||
|
||||
Reference in new issue
Block a user