docs: apply rules for comments
This commit is contained in:
1 parent
cd1da67586
commit
be96ee626c
30 files changed
+137
-200
No files matched your search
+11
-15
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user