docs: apply rules for comments

This commit is contained in:
Steffen Becker committed 2026-08-08 08:28:55 +00:00
1 parent cd1da67586
commit be96ee626c
30 files changed
+137 -200

No files matched your search

+11 -15
View File
@@ -60,11 +60,11 @@
// top toolbars target hfEditor instead of the body editor (activeEditor below).
let headerDoc: HfDoc = $state(loadHfDoc('header'));
let footerDoc: HfDoc = $state(loadHfDoc('footer'));
// First-page header/footer (Word "Different First Page"), shown on page 1 when on.
// First-page header/footer, shown on page 1 when the flag is on.
let headerFirstDoc: HfDoc = $state(loadHfDoc('header', 'first'));
let footerFirstDoc: HfDoc = $state(loadHfDoc('footer', 'first'));
let differentFirstPage: boolean = $state(loadDifferentFirstPage());
// Even-page header/footer (Word "Different Odd & Even Pages"), shown on even pages when on.
// Even-page header/footer, shown on even pages when the flag is on.
let headerEvenDoc: HfDoc = $state(loadHfDoc('header', 'even'));
let footerEvenDoc: HfDoc = $state(loadHfDoc('footer', 'even'));
let differentOddEven: boolean = $state(loadDifferentOddEven());
@@ -1112,11 +1112,9 @@
z-index: 190;
}
/* Toolbar scroll region: an overlay pinned over the editor (which fills the full
height), so the document scrolls under the floating island and stays visible in
the transparent gaps around it. pointer-events pass through everywhere except
the island pieces themselves. The z-index keeps the whole toolbar — incl. its
dropdowns, which live inside the translated stack — above the editor overlays. */
/* Toolbar scroll region: an overlay pinned over the full-height editor, so the document
scrolls under the floating island and stays visible in the gaps around it. Pointer
events pass through except on the island; z-index lifts it (and its dropdowns). */
.toolbar-region {
position: absolute;
top: 0;
@@ -1154,10 +1152,9 @@
/* Custom scrollbar strip under the toolbar (a native one auto-hides on macOS).
The thumb's width/position track the visible fraction of the stack; only rendered
when the toolbar overflows. */
/* Runs along the inside of the island's bottom edge. Lives inside the island
(header / expanded row) and is counter-translated (inline style) so it stays
put while the stack scrolls; z:1 paints it above the island surface but below
the dropdowns (z ≥ 200 in the same stacking context). */
/* Runs along the inside of the island's bottom edge, counter-translated (inline style)
so it stays put while the stack scrolls; z:1 paints it above the island surface but
below the dropdowns (z ≥ 200 in the same stacking context). */
.toolbar-scrollbar {
position: absolute;
left: 0;
@@ -1269,10 +1266,9 @@
gap: 2px;
}
/* Editable document title: borderless at rest, text auto-sizes to its
content (via the hidden .doc-name-sizer mirror), an animated underline
grows in on focus, and a pencil hint fades in on hover. Drives the
suggested save filename. */
/* Editable document title, borderless at rest: the text auto-sizes to its content (via
the hidden .doc-name-sizer mirror), an underline grows in on focus, a pencil hint
fades in on hover. Drives the suggested save filename. */
.doc-name {
position: relative;
display: inline-flex;
+2 -3
View File
@@ -41,9 +41,8 @@
});
// Keep the panel inside the window; a right-click near an edge otherwise opens a menu
// that runs off-screen. Measured from the layout box (offset*), not
// getBoundingClientRect, so the flip transform below can't feed back into the next
// measurement.
// that runs off-screen. Measured from the layout box (offset*), not getBoundingClientRect,
// so the flip transform below can't feed back into the next measurement.
$effect(() => {
void [top, left, items];
if (!menuEl) return;
+3 -5
View File
@@ -74,11 +74,9 @@
let headerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).header));
let mBottomPx = $derived(cmToPx(pageMargins.bottom));
let mTopPx = $derived(cmToPx(pageMargins.top));
// Effective top/bottom margins (px) for other pages vs. page 1's own header/footer
// (different first page); pageBreaks reads these to start each page's content below
// its header and end it above its footer.
// "rest" = every page ≥ 2; fold the even variant in (max) so even pages never overlap
// a taller even zone — pageBreaks uses one --pb-content-*-rest for all of them.
// Effective top/bottom margins (px) pageBreaks reads to keep content clear of the
// header/footer: "first" = page 1's own zone, "rest" = every page ≥ 2 with the even
// variant folded in (max), since one --pb-content-*-rest covers all of them.
let evenTopReach = $derived(differentOddEven ? hfReachPx(headerEvenDoc ?? null, headerDistPx) : 0);
let evenBottomReach = $derived(differentOddEven ? hfReachPx(footerEvenDoc ?? null, footerDistPx) : 0);
let effTopRest = $derived(Math.max(mTopPx, hfReachPx(headerDoc ?? null, headerDistPx), evenTopReach));
+11 -13
View File
@@ -82,8 +82,8 @@
return { top, left, width, height };
}
// The active (edited) zone grows to fit its content, keeping the anchored edge fixed
// (footer bottom at footerDistPx, header top at headerDistPx) so the Word-style
// boundary line sits exactly at the content's edge toward the body.
// (footer bottom at footerDistPx, header top at headerDistPx) so the boundary line
// sits exactly at the content's edge toward the body.
function activeZoneBox(zone: HfZone, page: number) {
const b = zoneBox(zone, page);
// A visible ProseMirror trailing break (a caret line past the real content) is
@@ -116,7 +116,7 @@
let headerEvenHtml = $derived(staticHtml(headerEvenDoc));
let footerEvenHtml = $derived(staticHtml(footerEvenDoc));
// Which variant a page shows (Word precedence): page 1 → first (if on), other even
// Which variant a page shows, in precedence order: page 1 → first (if on), other even
// pages → even (if on), everything else → default/odd. Each variant, when on, always
// shows its own (possibly empty) zone, never the default.
function variantFor(page: number): HfVariant {
@@ -338,7 +338,7 @@
opacity: 0.6;
}
/* Word-style affordance: a single dashed line at the edge facing the page body
/* Boundary affordance: a single dashed line at the edge facing the page body
(footer top, header bottom) instead of a rectangle around the content. */
.hf-active::after {
content: '';
@@ -355,7 +355,7 @@
bottom: 0;
}
/* Word-style zone label tab, pinned to the zone's top-left corner. */
/* Zone label tab, pinned to the zone's top-left corner. */
.hf-tag {
position: absolute;
transform: translateY(calc(-100% - 2px));
@@ -380,10 +380,9 @@
line-height: 1.15;
white-space: pre-wrap;
}
/* The live editor's editable root is also a `.tiptap`, so the global
`.paper .tiptap` rules (96px padding, 1123px min-height, page-background
gradient) leak in and push the text out of the clipped zone. Reset them —
higher specificity than `.paper .tiptap`, plus !important for the gradient. */
/* The live editor's editable root is also a `.tiptap`, so the global `.paper .tiptap`
rules (96px padding, 1123px min-height, page gradient) leak in and push the text out
of the clipped zone. Reset them — higher specificity, plus !important for the gradient. */
.hf-layer .hf-zone :global(.tiptap) {
padding: 0;
min-height: 0;
@@ -399,10 +398,9 @@
anchored edge while the real content stays put (set per active zone). */
margin-bottom: var(--hf-tb-offset, 0px);
}
/* The live editor's paragraph also matches `.paper .tiptap p` (margin-bottom
0.212cm), which the static `<p>` doesn't — with flex-end alignment that gap
shifts the text up on activation. Match the static zero margin (higher
specificity than `.paper .tiptap p`). */
/* The live editor's paragraph also matches `.paper .tiptap p` (margin-bottom 0.212cm),
which the static `<p>` doesn't — with flex-end alignment that gap shifts the text up
on activation. Match the static zero margin (higher specificity). */
.hf-layer .hf-zone :global(.tiptap p) {
margin: 0;
}
+1 -1
View File
@@ -38,7 +38,7 @@
{ id: 'none', on: [] },
];
// Word-like button states, re-read per transaction (tick) and pen change: a preset
// Button states, re-read per transaction (tick) and pen change: a preset
// is active when its boundaries all render the pen border; 'none' when borderless.
const active = $derived(
tick >= 0 && editor ? activeBorderPresets(editor.state, { widthPt, color }) : null,
+3 -4
View File
@@ -316,10 +316,9 @@
opacity: 0.9;
}
/* Bleeds to both panel edges below the grid, like the style gallery's manage row.
The panel is shrink-to-fit, so its width must come from the grid alone: width:0
keeps this row out of that calculation, and min-width then fills the resolved
width plus the two negative margins. The label truncates instead (title = full). */
/* Bleeds to both panel edges below the grid. The panel is shrink-to-fit, so its width
must come from the grid alone: width:0 keeps this row out of that calculation, and
min-width then fills the resolved width plus the two negative margins. */
.table-manage {
display: flex;
align-items: center;
+1 -1
View File
@@ -147,7 +147,7 @@
const DEFAULT_LINE_HEIGHT = '1';
const LINE_HEIGHTS = ['1', '1.15', '1.5', '2'];
// Word binds only these three; 1.15 has no key.
// Only these three have a key; 1.15 has none.
const LH_SHORTCUTS: Record<string, ShortcutId | undefined> = {
'1': 'lineHeight1', '1.5': 'lineHeight15', '2': 'lineHeight2',
};
+1 -1
View File
@@ -119,7 +119,7 @@ export const extensions = [
// LibreOffice (the sheet resolves a character style on that portion).
ListMarker.configure({ sheet: styleSheet }),
// resizable:false keeps TipTap's columnResizing plugin off; TableView, the two
// resize plugins, and ResizableTableRow supply Word-style drag instead. Table
// resize plugins, and ResizableTableRow supply the drag handling instead. Table
// (unlike TableKit) doesn't auto-add children, so ResizableTableRow is listed here.
Table.configure({ resizable: false, View: TableView }),
ResizableTableRow,
+1 -1
View File
@@ -164,7 +164,7 @@ export const ParagraphBox = Extension.create({
},
});
// Word-like active states for the paragraph border picker: a preset is active when every
// Active states for the paragraph border picker: a preset is active when every
// covered block renders exactly the pen border on every side the preset targets. `none`
// is active when every covered block is borderless. null when no block is selected.
export function activeParagraphBorderPresets(
@@ -1,11 +1,9 @@
import { Extension } from '@tiptap/core';
import { HEADER_SHADE } from './tableHeaderRow';
// Word/LibreOffice cell shading: a `backgroundColor` attr on table cells, rendered
// as the cell's CSS background and round-tripped to ODF fo:background-color
// (export/odt.ts exportTable, import/odt.ts convertTable). Set via the built-in
// setCellAttribute command (TableToolbar's ColorPicker). The default cell renderHTML
// emits no style, so this global attribute merges in cleanly.
// Cell shading: a `backgroundColor` attr on table cells, rendered as the cell's CSS
// background and round-tripped to ODF fo:background-color. Set via the built-in
// setCellAttribute, so it covers a whole CellSelection or just the cursor's cell.
export const TableCellBackground = Extension.create({
name: 'tableCellBackground',
+9 -15
View File
@@ -3,13 +3,9 @@ import type { CommandProps } from '@tiptap/core';
import type { EditorState } from '@tiptap/pm/state';
import { selectedRect, isInTable } from '@tiptap/pm/tables';
// Word/LibreOffice table borders: per-side attrs (`borderTop/Right/Bottom/Left`) on
// table cells. `null` = the table default (0.5pt solid black), `'none'` = no border,
// else the canonical `'<W>pt solid #RRGGBB'`. Round-trips to ODF fo:border-* and DOCX
// w:tcBorders (export/odt.ts exportTable, import/odt.ts convertTable, export/import
// docx.ts). setTableBorders applies a Word-style preset to the selected cell region,
// writing BOTH sides of every affected boundary (incl. the facing side of neighbour
// cells outside the region) so collapsed borders never disagree between two cells.
// Per-side border attrs (`borderTop/Right/Bottom/Left`) on table cells: `null` = the
// table default (0.5pt solid black), `'none'` = no border, else the canonical
// `'<W>pt solid #RRGGBB'`. Round-trips to ODF fo:border-* and DOCX w:tcBorders.
export const DEFAULT_BORDER_WIDTH_PT = 0.5;
export const DEFAULT_BORDER_COLOR = '#000000';
@@ -68,10 +64,9 @@ const SIDE_META: Record<BorderSide, { data: string; css: string }> = {
borderLeft: { data: 'data-border-left', css: 'border-left' },
};
// Walks every boundary a preset targets in the selected rect, reporting the two
// facing (cellPos, side) pairs of each; a pos is null at the table edge. Boundaries
// running inside a merged cell are skipped. Shared by the setTableBorders command
// and the active-preset reader below.
// Walks every boundary a preset targets in the selected rect, reporting both facing
// (cellPos, side) pairs — writing both is what keeps collapsed borders from disagreeing.
// A pos is null at the table edge; boundaries inside a merged cell are skipped.
function forEachBoundary(
rect: ReturnType<typeof selectedRect>,
preset: BorderPreset,
@@ -157,10 +152,9 @@ export const BORDER_PRESETS: BorderPreset[] = [
// (a border beats 'none', wider beats narrower; attr null = the table default).
type EffectiveBorder = { widthPt: number; color: string } | 'none';
// Word-like active states for the picker: a preset is active when every boundary it
// targets renders exactly the pen border (width + color) — so with the default thin
// pen a fresh table lights up every preset; picking a thicker pen turns them off.
// 'none' is active when the whole region renders borderless. null outside a table.
// Active states for the picker: a preset is active when every boundary it targets renders
// exactly the pen border (width + color), so the default thin pen lights up a fresh table
// and a thicker pen turns it off. 'none' = borderless region; null outside a table.
export function activeBorderPresets(
state: EditorState,
spec: Exclude<BorderSpec, null>,
@@ -7,7 +7,7 @@ import { TableMap, cellAround, pointsAtCell } from '@tiptap/pm/tables';
import { columnWeightsFromRow, columnPercents } from './tableView';
import { PX_PER_CM } from '../../storage/pageMargins';
// Word-style column resizing: an inner border trades width between its two columns
// Column resizing: an inner border trades width between its two columns
// (table width fixed); an outer edge moves the table's `marginLeft`/`marginRight` (cm)
// and the adjacent column. Live drag pokes the DOM; release writes the attrs.
@@ -111,7 +111,7 @@ function handleMouseMove(view: EditorView, event: MouseEvent): void {
}
}
// Outer-edge drag, Word-style: only the adjacent column gives up width, so every
// Outer-edge drag: only the adjacent column gives up width, so every
// other gridline stays put. All lengths in unscaled document px; the edge stops at
// the text margin (margins never go negative) and at the column's minimum width.
export function edgeResize(
+3 -6
View File
@@ -5,12 +5,9 @@ import type { EditorState } from '@tiptap/pm/state';
import { selectedRect, isInTable } from '@tiptap/pm/tables';
import { parseTableLook, type TableRegion } from '../../styles/tableStyles';
// Word/LibreOffice "header row"/"header column" as a styling preset: the first row (or
// column) gets bold text + a light grey fill, both of which round-trip to ODF/DOCX. The
// fill doubles as the marker, so no <table:table-header-rows> and no page repetition.
// A table carrying a *table style* owns these two areas through its Table Style Options
// instead, so there the toggle reads and writes that flag — one state, two surfaces
// (this button and the gallery's checkbox), never disagreeing.
// "Header row"/"header column" as a styling preset: the first row (or column) gets bold
// text + a light grey fill, both round-tripping to ODF/DOCX. The fill doubles as the
// marker, so no <table:table-header-rows> and no page repetition.
export const HEADER_SHADE = '#F2F2F2';
+1 -1
View File
@@ -7,7 +7,7 @@ import type { EditorView } from '@tiptap/pm/view';
import { cellAround } from '@tiptap/pm/tables';
import { domCellAround, tableColumnResizeKey } from './tableColumnResize';
// Word-style row-height drag: changes the row above the grid line, min-height
// Row-height drag: changes the row above the grid line, min-height
// semantics (stored as `rowHeight` in tableRow.ts). Live preview is a meta-only
// Decoration.node (no history); yields to column resize at a corner.
+3 -7
View File
@@ -5,13 +5,9 @@ import type { Node as PMNode } from '@tiptap/pm/model';
import { TableMap, selectedRect, isInTable } from '@tiptap/pm/tables';
import { columnWeightsFromRow } from './tableView';
// Word/LibreOffice "Split Cells…": divide the current cell (or a clean rectangular
// cell selection) into `cols` × `rows` sub-cells. prosemirror-tables only ships
// splitCell (un-merge), so this is custom. The table is a strict rectangular grid,
// so adding sub-columns/rows means inserting grid lines and letting every other
// cell that crosses a new line grow its span (bridge).
// Implemented by reading the grid via TableMap, expanding it, partitioning the
// cell's region, and rebuilding the table node in one step.
// "Split Cells…": divide the current cell (or a clean rectangular cell selection) into
// `cols` × `rows` sub-cells; prosemirror-tables only ships splitCell (un-merge). Reads
// the grid via TableMap, inserts grid lines (crossing cells bridge), rebuilds the table.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
+6 -8
View File
@@ -7,10 +7,9 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorView } from '@tiptap/pm/view';
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, pageContentHeightPx, type WrapMode } from './image';
// A text box / basic shape: a block-level frame with editable block content, a fill,
// a stroke, and the image's wrap model (inline = in-flow; left/right/topBottom float).
// Round-trips to ODF <draw:frame>/<draw:text-box> or <draw:custom-shape>, and to
// DOCX DrawingML <wps:wsp>/<wps:txbx>.
// A text box / basic shape: a block-level frame with editable block content, a fill, a
// stroke, and the image's wrap model (inline = in-flow; left/right/topBottom float).
// Round-trips to ODF <draw:frame>/<draw:text-box>/<draw:custom-shape> and DOCX <wps:wsp>.
export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
@@ -214,10 +213,9 @@ export const TextBox = Node.create({
},
});
// Node view: like ImageView, an axis-aligned wrapper reserves the rotated bounding
// box and a centered rotor carries fill/stroke/rotation plus the resize/rotate
// handles — but the rotor holds an editable contentDOM and auto-grows with its text,
// so a ResizeObserver re-fits the wrapper whenever the rotor's size changes.
// Node view: like ImageView, an axis-aligned wrapper reserves the rotated bounding box and
// a centered rotor carries fill/stroke/rotation plus the handles — but the rotor holds an
// editable contentDOM and auto-grows, so a ResizeObserver re-fits the wrapper.
class TextBoxView {
dom: HTMLElement;
contentDOM: HTMLElement;
+3 -4
View File
@@ -1,9 +1,8 @@
import { withShortcut } from '../i18n/shortcut';
// Every shortcut this app binds itself, in ProseMirror keymap syntax ('Mod' =
// Ctrl/Cmd). Ids are stable so a later remapping UI only overrides this map.
// TipTap's own defaults (Mod-b/i/u, Mod-Shift-s/h, Mod-z, Mod-Shift-7/8,
// Mod-Shift-l/e/r/j, Tab in tables) are not listed — they are not ours to bind.
// Every shortcut this app binds itself, in ProseMirror keymap syntax ('Mod' = Ctrl/Cmd).
// Ids are stable so a later remapping UI only overrides this map. TipTap's own defaults
// (Mod-b/i/u, Mod-Shift-s/h, Mod-z, Mod-Shift-7/8, …) are not ours to bind and not listed.
export const DEFAULT_SHORTCUTS = {
// Character formatting — LibreOffice's sub/superscript keys; Word's Ctrl+= pair
+2 -3
View File
@@ -119,9 +119,8 @@ function markerRunProps(node: TiptapNode): Writable<IRunStylePropertiesOptions>
}
// ---- numbering registry ----------------------------------------------------
// docx needs all numbering definitions up front (passed to the Document). We build
// the body paragraphs first, allocating one reference per top-level list and filling
// in each nesting level's format the first time we meet a list node at that depth.
// docx needs every numbering definition up front, so the body is built first: one
// reference per top-level list, each nesting level's format filled in when first met.
class Numbering {
readonly config: { reference: string; levels: ILevelsOptions[] }[] = [];
private map = new Map<string, ILevelsOptions[]>();
+19 -27
View File
@@ -98,9 +98,8 @@ const PBX = '\uE00C';
const TOC_SENT = '';
// Sentinel bracketing a hoisted text box's blocks (marker paragraphs TBX S{i} TBX …
// TBX E{i} TBX): replaceTextBoxes hoists the box's blocks to top level so every
// existing pass serializes them; applyTextBoxes wraps the region back into a
// <draw:frame>/<draw:text-box> or <draw:custom-shape>. U+E008.
// TBX E{i} TBX): hoisted to top level, they ride every existing pass; applyTextBoxes
// wraps the region back into a <draw:frame>/<draw:text-box> or custom shape. U+E008.
const TBX = '';
// Sentinel bracketing a hoisted multi-column section's blocks, same mechanism as
@@ -465,10 +464,9 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
};
}
// Swap each top-level textBox for a pair of marker paragraphs bracketing its child
// blocks, hoisted to top level. The hoisted blocks then ride every existing export
// pass unchanged (custom attrs, list styles, inline sentinels, images); applyTextBoxes
// re-wraps the serialized region into the drawing element. Top-level only by schema.
// Swap each top-level textBox for a pair of marker paragraphs bracketing its child blocks,
// hoisted to top level so they ride every existing export pass unchanged (custom attrs,
// list styles, inline sentinels, images); applyTextBoxes re-wraps the serialized region.
function replaceTextBoxes(doc: TiptapNode, boxes: TextBoxExport[]): TiptapNode {
if (!doc.content?.length) return doc;
const content: TiptapNode[] = [];
@@ -810,10 +808,9 @@ function applyTableStyleNames(odtBytes: Uint8Array, tables: (TableStyleRef | nul
`$1 style:parent-style-name="${odfStyleName(ref.name)}"`,
);
});
// Which conditional areas the table opts into: ODF's own table:use-*-styles pair with
// a table template, so the toggles survive a round trip through our own importer.
// Lookahead, not \b: a hyphen is a word boundary, so \b would also match
// <table:table-cell/-row/-column and consume the counter.
// Which conditional areas the table opts into (ODF's own table:use-*-styles), so the
// toggles survive a round trip. Lookahead, not \b: a hyphen is a word boundary, so \b
// would also match <table:table-cell/-row/-column and consume the counter.
content = content.replace(/<table:table(?=[\s>])[^>]*/g, (tag) => {
const ref = tables[nth++];
return ref ? `${tag} ${odfLookAttrs(ref.look)}` : tag;
@@ -2070,13 +2067,13 @@ function linkHrefOf(marks: TiptapNode['marks'] = []): string | undefined {
return href ? String(href) : undefined;
}
// Emit each text node as an odf-kit run; link-marked runs become <text:a> via addLink.
// forceBold bakes bold onto every run regardless of marks — used for header-row cells,
// whose bold is presentational (CSS) in the editor and so isn't stored as a mark.
// The sheet of the export in flight, so the run emitters can resolve character styles
// without threading it through every cell/list helper (mirrors docLangTag in docx.ts).
let exportSheet: StyleSheet = builtinStyleSheet();
// Emit each text node as an odf-kit run; link-marked runs become <text:a> via addLink.
// `force` bakes formatting onto every run regardless of marks — for header/region cells,
// whose formatting is presentational (CSS) in the editor and so isn't stored as marks.
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = [], force: TextProps = {}) {
const forced = Object.keys(force).length ? textFormatting(force) : null;
for (const node of content) {
@@ -2349,12 +2346,9 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
t.addRow((r: RowBuilder) => {
for (const cell of row.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
// Emit the cell's runs (SEG-separated) and record its block descriptor.
// addCell runs synchronously, so the push is in document order — matching
// how applyCellBlocks later walks cells in content.xml.
// colSpan/rowSpan make odf-kit emit number-columns/rows-spanned and the
// <table:covered-table-cell> placeholders; those never match the
// applyCellBlocks cell regex, so cellBlocks stays one-per-real-cell aligned.
// Emit the cell's runs (SEG-separated) and record its descriptor; addCell is
// synchronous, so the push stays in document order, matching applyCellBlocks'
// walk. Spans make odf-kit emit covered cells, which its cell regex never matches.
const opts: CellOptions = { padding: CELL_PADDING };
const colspan = (cell.attrs?.colspan as number) ?? 1;
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
@@ -2826,11 +2820,9 @@ export type HfExport = {
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet()): 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 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).
// Images become IMG sentinels before serialization; applyImages resolves them and writes
// the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks
// (so PGB misses their blocks) and before the inline passes (which then cover them).
exportSheet = styles;
const images: ImageExport[] = [];
const tocs: TocExport[] = [];
@@ -2859,7 +2851,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
evenHeaderPara = replaceHfImages(evenHeaderPara, hfImages);
evenFooterPara = replaceHfImages(evenFooterPara, hfImages);
// With the flag on, page 1 is independent: whenever a side has a zone on either
// variant, emit both (an empty one blanks its side, matching the editor and Word).
// variant, emit both — an empty one blanks its side, as the editor shows it.
if (differentFirstPage) {
const empty = (): TiptapNode => ({ type: 'paragraph', content: [] });
if (headerPara || firstHeaderPara) { headerPara ??= empty(); firstHeaderPara ??= empty(); }
@@ -3067,7 +3059,7 @@ function hfParaProps(para: TiptapNode): string[] {
// Header/footer post-processing on styles.xml: resolve LBR/PGC sentinels, apply the
// paragraph alignment to the Header/Footer styles, and rewrite the geometry to the
// Word-style mapping (page margin = HF distance, min-height fills up to the body margin).
// ODF's own mapping (page margin = HF distance, min-height fills up to the body margin).
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number, firstHeaderPara: TiptapNode | null = null, firstFooterPara: TiptapNode | null = null, pageCount = 1, hfImages: ImageExport[] = [], evenHeaderPara: TiptapNode | null = null, evenFooterPara: TiptapNode | null = null): Uint8Array {
if (!headerPara && !footerPara && !firstHeaderPara && !firstFooterPara && !evenHeaderPara && !evenFooterPara) return odtBytes;
+4 -5
View File
@@ -203,9 +203,8 @@ function materializeListMarkers(root: HTMLElement): void {
}
// Off-screen raster of the live .paper at scale 1, captured to one tall canvas covering
// every page (page i occupies [i*cycle, i*cycle+pageH]). Shared by the PDF download and
// the raster print path; the caller invokes cleanup() once done reading `clone`. `scale`
// is the oversampling factor @ A4 (2 ≈ 192 dpi, 3 ≈ 288 dpi).
// every page (page i occupies [i*cycle, i*cycle+pageH]); the caller invokes cleanup() once
// done reading `clone`. `scale` = oversampling @ A4 (2 ≈ 192 dpi, 3 ≈ 288 dpi).
export async function renderPaperToCanvas(opts: PdfOptions, scale = 2): Promise<{
canvas: HTMLCanvasElement; clone: HTMLElement; pages: number;
pageW: number; pageH: number; cycle: number; scale: number; cleanup: () => void;
@@ -355,11 +354,11 @@ export interface PrintPdfOptions {
pageFormat?: PageFormat;
headerDoc: HfDoc;
footerDoc: HfDoc;
// First-page overrides (Word "Different First Page"); rendered via @page :first.
// First-page overrides, rendered via @page :first.
headerFirstDoc?: HfDoc;
footerFirstDoc?: HfDoc;
differentFirstPage?: boolean;
// Even-page overrides (Word "Different Odd & Even Pages"); rendered via @page :left
// Even-page overrides, rendered via @page :left
// (in an LTR document left pages are the even ones), which :first still overrides.
headerEvenDoc?: HfDoc;
footerEvenDoc?: HfDoc;
+5 -8
View File
@@ -246,9 +246,8 @@ function documentLanguage(stylesDoc: Document | null, warnings: Set<string>): Do
}
// ---- block conversion (paragraphs, lists, tables) --------------------------
// A Word/LibreOffice TOC is a `TOC` field spanning several paragraphs (begin+instrText
// first, cached entries between, end last), each entry itself a nested PAGEREF field — so
// field depth is tracked (across one convertBlocks call) to match the TOC's own end.
// A TOC is a `TOC` field spanning several paragraphs, each entry a nested PAGEREF field,
// so field depth is tracked (across one convertBlocks call) to match the TOC's own end.
type TocFieldState = { fieldDepth: number; tocDepth: number; instr: string[] };
function scanTocField(p: Element, st: TocFieldState): { emit: boolean } {
@@ -880,11 +879,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
// date/time field (replaced by its live node).
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime);
// Route a drawing/pict result: an image is inline; a text box is a block node
// riding ctx.pendingBlocks. The one-paragraph header/footer schema holds inline
// (as-character) images but not boxes — those are dropped with a warning. A floating
// drawing (page background, watermark, fold marks) can't be placed in the one-paragraph
// zone and, sized to the page, would overlay the whole document, so it's dropped there.
// Route a drawing/pict result: an image is inline, a text box a block node riding
// ctx.pendingBlocks. The one-paragraph header/footer zone takes as-char images only —
// boxes and floating page-sized drawings (backgrounds, watermarks) are dropped there.
const pushDrawn = (n: Node | null, floating: boolean) => {
if (!n) return;
if (hfFields) {
+3 -6
View File
@@ -1,9 +1,6 @@
// Image-format handling shared by the ODF and DOCX importers. An <img> can only
// render a fixed set of formats; anything else (WMF/EMF/SVM/TIFF/…) is either decoded
// client-side (see convertUnsupportedImages) or skipped with a warning rather than
// emitted as a broken image. The format is resolved from the file extension first, then
// confirmed/recovered from the bytes' magic number so a mislabelled or extensionless
// entry still works.
// Image formats, shared by both importers. An <img> renders only a fixed set, so the
// rest (WMF/EMF/SVM/TIFF/…) is decoded client-side (convertUnsupportedImages) or skipped
// with a warning. Resolved by extension, then by magic number so mislabels still work.
import { unzipSync } from 'fflate';
const MIME_BY_EXT: Record<string, string> = {
+21 -33
View File
@@ -65,10 +65,9 @@ export interface OdtImportResult {
// paragraph's indent lives in the list style rather than its own margin.
type BlockKind = 'body' | 'list' | 'cell';
// `files` is the full unzipped archive so image converters can read Pictures/
// binaries; imageCache dedupes repeated hrefs into one data-URI. pendingBlocks is
// the side channel for text boxes/shapes found while converting inline content —
// they are block nodes, so convertBlocks flushes them after the anchor paragraph.
// `files` is the full unzipped archive so image converters can read Pictures/ binaries;
// imageCache dedupes repeated hrefs into one data-URI. pendingBlocks is the side channel
// for text boxes found in inline content — convertBlocks flushes them after the anchor.
type Ctx = {
resolver: StyleResolver;
// ODF style name → display name, and the names blocks actually reference: the
@@ -119,11 +118,9 @@ function frameRotationDeg(el: Element): number {
return ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
}
// Set rotation + wrap attrs shared by images, text boxes and shapes. A non-as-char
// anchor or an explicit style:wrap makes the element floating (free x/y positions
// collapse to the nearest side — the editor's anchor-float model). An explicit
// as-char anchor is always inline: LibreOffice's named Graphics style carries a
// style:wrap that would otherwise float every inherited as-char frame.
// Rotation + wrap attrs, shared by images, text boxes and shapes. A non-as-char anchor or
// an explicit style:wrap floats the element (free x/y collapse to the nearest side). An
// explicit as-char anchor stays inline: LibreOffice's Graphics style carries a style:wrap.
function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>, gp: PropMap): void {
const deg = frameRotationDeg(el);
if (deg) attrs.rotation = deg;
@@ -164,11 +161,9 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
return { type: 'image', attrs };
}
// A shape's fill color. fo:background-color (LibreOffice's per-shape fill) wins over
// draw:fill when set. A drawn shape's fill defaults to solid when the keyword is
// absent — LibreOffice omits draw:fill and only writes "none" to turn it off, taking
// the color from the (usually inherited) draw:fill-color. defaultSolid is false for
// plain text frames, whose fill defaults to none.
// A shape's fill color; fo:background-color (LibreOffice's per-shape fill) beats draw:fill.
// A drawn shape's fill defaults to solid when the keyword is absent — LibreOffice only
// writes "none" to turn it off. defaultSolid is false for plain text frames (fill none).
function shapeFill(gp: PropMap, defaultSolid: boolean): string | null {
const bg = gp['fo:background-color'];
if (bg !== undefined) return bg === 'transparent' || bg === 'none' ? null : normalizeColor(bg) ?? null;
@@ -200,10 +195,9 @@ function shapeStroke(gp: PropMap, defaultSolid: boolean): { color: string | null
return { color, widthPt: color ? lengthToPt(gp['svg:stroke-width']) : null };
}
// Fill/stroke attrs from a shape's graphic style, suppressing the editor defaults
// (white fill, 1pt black stroke). Absent/none → explicit null (transparent/no border),
// since omitting the attr would re-apply the default. defaultSolid marks drawn shapes,
// whose fill/stroke default to solid (unlike a plain text frame's).
// Fill/stroke attrs from a shape's graphic style, suppressing the editor defaults (white
// fill, 1pt black stroke). Absent/none → explicit null, since omitting the attr would
// re-apply the default. defaultSolid marks drawn shapes, whose fill/stroke default solid.
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>, defaultSolid: boolean): void {
const fillColor = shapeFill(gp, defaultSolid);
if (fillColor !== '#FFFFFF') attrs.fillColor = fillColor;
@@ -363,7 +357,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
// Whole-document columns declared on the page layout (Word-style) instead of a
// Whole-document columns declared on the page layout instead of a
// text:section: wrap the body's wrappable runs the way a section would be.
const pageCols = resolver.pageColumns();
if (pageCols) {
@@ -536,9 +530,8 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
const out: Node[] = [];
// Emit an anchor block, then any text boxes found inside it (block nodes riding
// ctx.pendingBlocks). In the body they follow the anchor at top level — and an
// empty anchor (our own export's wrapper paragraph) is dropped; elsewhere (cells,
// other boxes) their blocks are unwrapped in place.
// ctx.pendingBlocks). In the body they follow the anchor at top level, and an empty
// anchor (our export's wrapper) is dropped; in cells/boxes they unwrap in place.
const pushWithPending = (anchor: Node | null) => {
const pending = ctx.pendingBlocks.splice(0);
const anchorIsEmpty = anchor?.type === 'paragraph' && !anchor.content?.length;
@@ -1301,10 +1294,9 @@ function outlineHeadingEls(listEl: Element): Element[] | null {
return out.length ? out : null;
}
// `inheritedStyleName`: nested text:list elements usually carry no style-name of
// their own — the outermost list's style governs, with one level def per depth.
// `govMultilevel`: this list sits inside a multilevel (display-levels) chain, so an
// explicit numbering here must never be suppressed to null (null = rejoin the chain).
// `inheritedStyleName`: a nested text:list usually carries no style-name of its own — the
// outermost list's style governs, one level def per depth. `govMultilevel`: inside a
// display-levels chain, so an explicit numbering here is never suppressed (null = rejoin).
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number, govMultilevel = false, baseCycle: OrderedCycle = ROOT_ORDERED_CYCLE): Node | null {
const styleName = el.getAttributeNS(NS.text, 'style-name') ?? inheritedStyleName;
const levelDef = listLevelDef(ctx.resolver.listStyle(styleName), depth);
@@ -1430,13 +1422,9 @@ function listLevelDef(listStyle: Element | null, depth: number): Element | null
// ---- tables ------------------------------------------------------------------------
// ODF border ("0.5pt solid #000000" / "none" / absent) → border attr value: null for
// the editor default (0.5pt black, tolerance for producer unit rounding), 'none' for
// no border, else the canonical '<W>pt solid #RRGGBB'. Non-solid styles are coerced
// to solid (the editor only renders solid lines).
// treatDefaultAsNull: table cells collapse the 0.5pt-black default to null (= the table's
// own default border). Paragraph borders have no such default, so they pass false to keep
// a declared thin-black rule as an explicit canonical value.
// ODF border ("0.5pt solid #000000" / "none" / absent) → the canonical '<W>pt solid
// #RRGGBB', 'none', or null; non-solid styles coerce to solid. treatDefaultAsNull folds
// the 0.5pt-black default to null — cells inherit the table's, paragraph borders don't.
export function borderAttrFromOdf(raw: string | null | undefined, treatDefaultAsNull = true): string | null {
if (!raw || raw === 'none' || raw === 'hidden') return 'none';
let widthPt: number | null = null;
+3 -4
View File
@@ -462,7 +462,7 @@ export class StyleResolver {
// Header/footer of the master page: the content elements, the vertical space the
// zone occupies below/above the page margin (height + body-side spacing, for the
// Word-style body-margin reconstruction), and whether per-page variants exist.
// body-margin reconstruction), and whether per-page variants exist.
masterPageHF(): {
header: Element | null;
footer: Element | null;
@@ -506,9 +506,8 @@ export class StyleResolver {
?? lengthToCm(props.getAttributeNS(NS.fo, 'min-height'))
?? 0;
// style:dynamic-spacing="true" makes the header↔body gap collapsible (the header
// grows into it), so LibreOffice doesn't push the body down by the full spacing —
// reserving it as a fixed body margin is what shoves the text down. Only the fixed
// (non-dynamic) spacing is added to the body margin.
// grows into it), so the body isn't pushed down by it. Only fixed (non-dynamic)
// spacing is added to the body margin.
const dynamic = props.getAttributeNS(NS.style, 'dynamic-spacing') === 'true';
const spacing = dynamic ? 0 : (lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0);
return height + spacing;
+6 -8
View File
@@ -1,8 +1,6 @@
// Header/footer content: one single-paragraph TipTap doc per zone (hfExtensions
// schema). The 'default' variant repeats on every page (odd pages when odd/even is
// on); 'first' (Word "Different First Page" / ODF header-first) overrides page 1;
// 'even' (Word "Different Odd & Even Pages" / ODF header-left) overrides even pages.
// Precedence: first (page 1) > even (even pages) > default. null = empty zone.
// Header/footer content: one single-paragraph TipTap doc per zone (hfExtensions schema).
// 'default' repeats on every page (odd pages when odd/even is on), 'first' overrides
// page 1, 'even' overrides even pages — also their precedence. null = empty zone.
export type HfZone = 'header' | 'footer';
export type HfVariant = 'default' | 'first' | 'even';
@@ -36,9 +34,9 @@ export function saveDifferentOddEven(on: boolean): void {
else localStorage.removeItem(DIFFERENT_ODD_EVEN_KEY);
}
// Word's default distance from the page edge to the header/footer text. The body
// margin stays the body margin (Word semantics); export/import convert to ODF's
// margin-to-header model (see export/odt.ts applyHfPostProcess).
// Default distance from the page edge to the header/footer text; the body margin stays
// the body margin. Export/import convert to ODF's margin-to-header model (see
// export/odt.ts applyHfPostProcess).
export const HF_DISTANCE_CM = 1.25;
// Per-zone distance from the page edge to the header (from top) / footer (from
+1 -2
View File
@@ -120,8 +120,7 @@ export function builtinStyleSheet(): StyleSheet {
// Inheritance order: every style directly followed by its own children, so the manager's
// indent matches the tree. Siblings sort built-ins first (as listed above), then by name.
// `Heading` is an abstract parent (LibreOffice's, holding what all levels share) — never
// assignable, so the gallery hides it (its children stay); the manager passes withAbstract.
// `Heading` is abstract (never assignable), so only withAbstract callers see it.
export function styleOrder(sheet: StyleSheet, withAbstract = false, family: StyleFamily = 'paragraph'): Style[] {
if (family === 'table') return []; // table styles have no inheritance — listed flat
const styles = family === 'character' ? sheet.character : sheet.paragraph;
+2 -3
View File
@@ -1,7 +1,6 @@
// LibreOffice round-trip leg: editor JSON -> buildOdt -> `soffice --convert-to odt`
// re-save -> importOdt -> compare. Requires LibreOffice (soffice) on PATH; the whole
// suite is skipped when it is absent (so plain `npm test` / CI stay green).
// jsdom (vitest `environment`) supplies the global DOMParser.
// re-save -> importOdt -> compare. Requires soffice on PATH; the whole suite self-skips
// when it is absent (so plain `npm test` / CI stay green).
import { describe, it, expect } from 'vitest';
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
+3 -3
View File
@@ -780,7 +780,7 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
check('shape fill/stroke: explicit none → null stroke', none.attrs.strokeColor === null, none.attrs);
});
it('wraps the body in columns when the page layout declares them (Word-style)', () => {
it('wraps the body in columns when the page layout declares them', () => {
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles 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">
<office:automatic-styles>
@@ -828,7 +828,7 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
});
describe('Leg 3: header/footer → buildOdt → importOdt', () => {
it('round-trips header/footer content, fields, and the Word-style geometry mapping', async () => {
it('round-trips header/footer content, fields, and the geometry mapping', async () => {
const header: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [
{ type: 'text', text: 'Bericht ', marks: [{ type: 'bold' }, { type: 'textStyle', attrs: { color: '#C00000' } }] },
{ type: 'text', text: '2026' },
@@ -851,7 +851,7 @@ describe('Leg 3: header/footer → buildOdt → importOdt', () => {
firstDiff(normalize(header), normalize(hfRes.header)));
check('hf: footer round-trips', firstDiff(normalize(footer), normalize(hfRes.footer)) === null,
firstDiff(normalize(footer), normalize(hfRes.footer)));
// The body margins survive the Word-style header/footer geometry mapping.
// The body margins survive the header/footer geometry mapping.
const hm = hfRes.margins!;
check('hf: body margins preserved through geometry mapping',
!!hm && Math.abs(hm.top - 3) < 0.05 && Math.abs(hm.bottom - 2) < 0.05 &&
@@ -17,9 +17,8 @@ function docx(files: Record<string, string | Uint8Array>): Uint8Array {
}
// Header with a floating full-page VML background ("Falzmarken"-style, position:absolute,
// A4-sized) followed by a genuine inline (as-character) logo. Word templates place page
// backgrounds/watermarks in the header this way; our one-paragraph zone can't position
// them, and at full size they overlay the whole document — so they must be dropped.
// A4-sized) followed by a genuine inline (as-character) logo. Templates put backgrounds
// and watermarks there; the one-paragraph zone can't position them, so they're dropped.
const HEADER_XML = `<?xml version="1.0"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="${REL}"
+2 -3
View File
@@ -1,7 +1,6 @@
// Unit test for the custom splitCellInto command: drives it against a ProseMirror
// EditorState (no DOM/Editor needed) and inspects the rebuilt table. Covers the
// two Word/LibreOffice cases: splitting a plain cell (adds grid lines + bridges
// neighbours) and splitting a merged cell (un-merge).
// EditorState (no DOM/Editor needed) and inspects the rebuilt table. Covers both cases:
// a plain cell (adds grid lines + bridges neighbours) and a merged cell (un-merge).
import { describe, it, expect } from 'vitest';
import { getSchema } from '@tiptap/core';
import { EditorState, TextSelection } from '@tiptap/pm/state';