feat: a reading can be printed over its base text
Ruby, as both products' phonetic guide writes it: one element holding a base and its reading, so the editor keeps it as one inline atom and the browser sets it as a real <ruby>. ODF gets <text:ruby> plus a minted ruby-family style (centred, above — probed: LibreOffice writes that style back unchanged); Word a <w:ruby> run from a post-pack pass, the package having none. Both legs verified through a LibreOffice re-save, our .docx to .odt included.
This commit is contained in:
1 parent
405b175e49
commit
20e6dcad0e
17 files changed
+405
-6
No files matched your search
+2
-1
@@ -58,6 +58,7 @@
|
||||
- AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options, one checkbox per rule): typographic quotes per document language (`"…"` / `„…“`), its dash matrix (`A - B` → `A – B`, `A--B` → `A—B`), its replacement table (`-->` → `→`, `(C)` → `©`, `...` → `…`), capitalize the first letter of a sentence — with an abbreviation exception list — and TWo INitial CApitals. URL recognition and auto-list ride the same switches
|
||||
- Word completion (the same dialog's own section, as LibreOffice keeps it): every word of at least eight letters is remembered as it is typed, and typing its first three letters offers the rest in grey after the caret — Enter takes the offer, Esc drops it. It is offered while typing only, so an Enter meant to split a paragraph never completes a word instead, and the offer stays outside the document until it is accepted. Minimum length, an appended space and clearing the collected words are all settable
|
||||
- AutoText (LibreOffice's Tools ▸ AutoText, Word's Insert ▸ Quick Parts): keep a selected block of text under a name and a shortcut, then insert it from the library or by typing the shortcut and pressing F3 — LibreOffice's own key. An entry keeps its formatting, and the library belongs to the app rather than to a document, as it does in both products
|
||||
- Ruby annotations (Insert ▸ Phonetic guide — LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide): the reading printed over its base text, the selected text proposed as the base. Round-trips as ODF `text:ruby` with its ruby-family style and as Word's `w:ruby` run — verified through LibreOffice both ways, including our `.docx` converted to `.odt` by it
|
||||
- Automatic hyphenation for the whole document (Layout ▸ Hyphenation): the browser hyphenates in the document's own language, which shortens a justified paragraph the way LibreOffice does. Round-trips as ODF `fo:hyphenate` on the base style — where LibreOffice keeps it, in its *text* properties — and Word's `w:autoHyphenation`
|
||||
- Manual line breaks (Shift+Enter)
|
||||
|
||||
@@ -163,7 +164,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
|
||||
- Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog
|
||||
- Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare
|
||||
- Thesaurus and grammar check
|
||||
- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby)
|
||||
- Vertical writing modes (ODF `tb-rl`), and a ruby annotation's own alignment and position — both products' defaults are what we write
|
||||
- Password-protected ODT/DOCX; digital signatures
|
||||
- Split view: two views of one document, scrolled independently
|
||||
- A committed corpus of documents we author ourselves, so CI can test against real files
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
// LibreOffice's Format ▸ Asian Phonetic Guide / Word's Phonetic Guide: the base text
|
||||
// and the reading printed over it. Alignment and position follow both defaults
|
||||
// (centred, above), which is what the exports write.
|
||||
let { open = $bindable(false), editor }: { open?: boolean; editor: Editor | null } = $props();
|
||||
|
||||
let dialogEl = $state<HTMLDialogElement | null>(null);
|
||||
let base = $state('');
|
||||
let reading = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const el = dialogEl;
|
||||
if (!el) return;
|
||||
if (open && !el.open) el.showModal();
|
||||
else if (!open && el.open) el.close();
|
||||
});
|
||||
|
||||
// The selected text is the base, as it is in both dialogs.
|
||||
$effect(() => {
|
||||
if (!open || !editor) return;
|
||||
const { from, to } = editor.state.selection;
|
||||
base = to > from ? editor.state.doc.textBetween(from, to, ' ') : '';
|
||||
reading = '';
|
||||
});
|
||||
|
||||
function insert() {
|
||||
if (!base.trim()) return;
|
||||
editor?.chain().focus().insertRuby({ base, text: reading }).run();
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
onclose={() => (open = false)}
|
||||
onclick={(e) => e.target === dialogEl && (open = false)}
|
||||
aria-label={t().ruby.title}
|
||||
>
|
||||
<div class="body">
|
||||
<h2>{t().ruby.title}</h2>
|
||||
|
||||
<label class="row">
|
||||
<span>{t().ruby.base}</span>
|
||||
<input bind:value={base} />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span>{t().ruby.reading}</span>
|
||||
<input bind:value={reading} onkeydown={(e) => e.key === 'Enter' && insert()} />
|
||||
</label>
|
||||
|
||||
{#if base.trim()}
|
||||
<p class="preview"><ruby>{base}<rt>{reading}</rt></ruby></p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<span class="spacer"></span>
|
||||
<button onclick={() => (open = false)}>{t().common.cancel}</button>
|
||||
<button class="primary" disabled={!base.trim()} onclick={insert}>{t().common.insert}</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
/* The global reset zeroes every margin, which also takes the auto centring a
|
||||
modal <dialog> gets by default. */
|
||||
margin: auto;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 320px;
|
||||
padding: 18px 20px 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
h2 { font-size: 1rem; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 8px; }
|
||||
.row span { flex: 0 0 5.5rem; }
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: 4px 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 6px 0;
|
||||
font-family: var(--font-serif, serif);
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.preview rt { font-size: 0.5em; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 8px; padding-top: 4px; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.actions button {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: 5px 14px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { background: var(--color-btn-hover); }
|
||||
.actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
.actions .primary {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -137,6 +137,8 @@ export const STROKED = {
|
||||
footer: ['M3.5 1.75h9v12.5h-9z', 'M5.4 4h5.2M5.4 6.1h5.2M5.4 8.2h3.1'],
|
||||
pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'],
|
||||
pageBreak: ['M2 2.5h12M2 13.5h12', 'M8 5.5v5.2', 'M6.2 8.9 8 10.7l1.8-1.8'],
|
||||
// A big glyph with its small reading over it: what the annotation prints.
|
||||
ruby: ['M4.5 13.2V6.6M4.5 6.6h4.2M8.7 6.6v6.6', 'M11 4.6h3.2M12.6 4.6v-2.4'],
|
||||
// A page with one saved block picked out of its text: the stored entry.
|
||||
quickParts: ['M3 1.75h10v12.5H3z', 'M5.2 4.4h5.6v3H5.2z', 'M5.2 9.9h5.6M5.2 12h3.4'],
|
||||
} as const;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import BookmarkDialog from '../../BookmarkDialog.svelte';
|
||||
import CrossRefDialog from '../../CrossRefDialog.svelte';
|
||||
import FormulaDialog from '../../FormulaDialog.svelte';
|
||||
import RubyDialog from '../../RubyDialog.svelte';
|
||||
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
|
||||
import { OPEN_LINK_DIALOG_EVENT } from '../../../editor/extensions/link';
|
||||
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../../../editor/extensions/bookmark';
|
||||
@@ -37,6 +38,8 @@
|
||||
onAutoText?: () => void;
|
||||
} = $props();
|
||||
|
||||
let rubyOpen = $state(false);
|
||||
|
||||
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
|
||||
let isLink = $derived(tick >= 0 && !!editor?.isActive('link'));
|
||||
let bmNames = $derived(tick >= 0 && editor && !hfActive ? bookmarkNames(editor.state.doc) : []);
|
||||
@@ -264,6 +267,14 @@
|
||||
disabled={!editor || !onAutoText}
|
||||
onclick={() => onAutoText?.()}
|
||||
/>
|
||||
<RibbonButton
|
||||
variant="big"
|
||||
icon="ruby"
|
||||
label={t().ruby.title}
|
||||
title={t().ruby.title}
|
||||
disabled={!editor}
|
||||
onclick={() => (rubyOpen = true)}
|
||||
/>
|
||||
<div class="rb-captioned" use:captionClicks>
|
||||
<DateTimePicker
|
||||
bind:open={dateOpen}
|
||||
@@ -296,6 +307,7 @@
|
||||
onchange={onImageFile}
|
||||
/>
|
||||
|
||||
<RubyDialog bind:open={rubyOpen} {editor} />
|
||||
<FormulaDialog bind:open={formulaOpen} initialLatex={formulaLatex} initialDisplay={formulaDisplay} onApply={applyFormula} />
|
||||
|
||||
<!-- Word's Equation button is a π, as its Symbol button beside it is an Ω. Same
|
||||
|
||||
@@ -57,6 +57,7 @@ import { SequenceField } from './extensions/caption';
|
||||
import { IndexEntry } from './extensions/indexEntry';
|
||||
import { BibliographyEntry } from './extensions/bibliographyEntry';
|
||||
import { CrossReference } from './extensions/crossReference';
|
||||
import { Ruby } from './extensions/ruby';
|
||||
import { Formula } from './extensions/formula';
|
||||
import { TextBox } from './extensions/textBox';
|
||||
import { Columns } from './extensions/columns';
|
||||
@@ -126,6 +127,8 @@ export const extensions = [
|
||||
// Inline reference to a bookmark, showing its text or its page; kept live by its
|
||||
// node view. Round-trips to ODF <text:bookmark-ref> and DOCX REF/PAGEREF fields.
|
||||
CrossReference,
|
||||
// A reading printed over its base text; round-trips to ODF <text:ruby> and DOCX w:ruby.
|
||||
Ruby,
|
||||
// Mathematical formula; stores LaTeX, renders native MathML, round-trips to an ODF
|
||||
// embedded formula object and DOCX OMML. See docs/architecture/formulas.md.
|
||||
Formula,
|
||||
|
||||
@@ -32,6 +32,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
|
||||
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row.
|
||||
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph is **boxed to the frame it captions** (`captionPlacement`), so it stands under the picture instead of at the margin: LibreOffice frames picture and caption together, and a loose paragraph carries that over as `indent`/`indentRight` — the frame's own span in the column, measured (`frameSpanCm`), since that span comes from the wrap, the offset and the column, not from one attr. The indent is also what lifts the caption clear of a **side-wrapped** frame: a line with no room beside a float drops below it. It is centred in that box only under a frame that is itself centred; a block with no frame at all has only its own alignment to pass on. `getFrameDebug` reports the same two spans for the debug dump. `CaptionDialog.svelte` (References tab) owns the insert.
|
||||
- **`ruby.ts`** — a reading printed over its base (LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying both halves, because that is what both formats write: one element holding a base and a reading, edited as a unit (`RubyDialog.svelte`). Rendered as a real `<ruby>`, so the browser sets it. Alignment and position are both products' defaults and are not offered.
|
||||
- **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one.
|
||||
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement. A `FormatSpec` (weight/slant/underline and a paragraph style, LibreOffice's Format… and its Paragraph Styles box) narrows a text search to the runs carrying it, and **with no term is the search**: every such run, or every paragraph in the style, empty ones included. The same spec on the replacement applies it — and a replacement with **no text** keeps the text and only reformats, which is how both dialogs restyle a document without retyping it. A style also switches the node type, as `setParagraphStyle` does, so the extension takes the same `sheet` option.
|
||||
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
|
||||
// A ruby annotation: the small reading printed over a run of text (LibreOffice's
|
||||
// Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying
|
||||
// both halves, because that is what both formats write — one element holding a base
|
||||
// and its reading, edited as a unit.
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
ruby: {
|
||||
insertRuby: (attrs: { base: string; text: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Ruby = Node.create({
|
||||
name: 'ruby',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
base: {
|
||||
default: '',
|
||||
parseHTML: (el) => baseTextOf(el as HTMLElement),
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
text: {
|
||||
default: '',
|
||||
parseHTML: (el) => (el as HTMLElement).querySelector('rt')?.textContent?.trim() ?? '',
|
||||
renderHTML: (attrs) => ({ 'data-ruby': String(attrs.text ?? '') }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'ruby' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
return [
|
||||
'ruby',
|
||||
mergeAttributes(HTMLAttributes, { class: 'pm-ruby' }),
|
||||
String(node.attrs.base ?? ''),
|
||||
['rt', {}, String(node.attrs.text ?? '')],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertRuby: (attrs) => ({ commands }) => {
|
||||
const base = attrs.base.trim();
|
||||
if (!base) return false;
|
||||
return commands.insertContent({ type: this.name, attrs: { base, text: attrs.text.trim() } });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The base is everything the element holds outside its <rt> (and <rp>, the brackets a
|
||||
// reader without ruby support falls back to).
|
||||
function baseTextOf(el: HTMLElement): string {
|
||||
let out = '';
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
const tag = child.nodeType === 1 ? (child as HTMLElement).tagName.toLowerCase() : '';
|
||||
if (tag !== 'rt' && tag !== 'rp') out += child.textContent ?? '';
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
@@ -36,6 +36,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
- **Captions** (`replaceSequenceFields`/`applySequenceFields`): a `SEQ` sentinel becomes a `<text:sequence>` carrying LibreOffice's own `text:formula="ooow:<name>+1"` (what makes its reader recount on load) plus a `<text:sequence-decl>` per counter used; DOCX gets a `SimpleField` `SEQ Figure \* ARABIC`. Both keep the editor's resolved rank as the field's cached result.
|
||||
- **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → `<text:change-start/>`/`<text:change-end/>`; a deletion's text is **cut out of the run** and left as one `TCD` → `<text:change/>`, the text itself going into the `<text:tracked-changes>` registry `applyRevisions` builds right after `<office:text>` (a `<text:deletion>` holds the deleted paragraphs, a `<text:insertion>` only its `<office:change-info>`). The registry's `<dc:creator>`/`<dc:date>` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments.
|
||||
- **Bibliography** (`replaceBibEntries`/`applyBibEntries`, `applyBibliographyDocx`): a `BIB` sentinel becomes a `<text:bibliography-mark>` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `<text:bibliography>` index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (`CITATION "key"`) and keeps the sources in a **custom-XML part** the `docx` package knows nothing about, so the post-pack pass mints all four pieces itself: `customXml/item1.xml` (`b:Sources`), its `itemProps1.xml`, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the `CITATION` field but no sources at all.)
|
||||
- **Ruby** (`replaceRuby`/`applyRuby`, `applyRubyDocx`): an `RBY` sentinel becomes a `<text:ruby>` holding both halves plus one minted `style:family="ruby"` automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its own `loext:` mirror of the position). Word's is a `<w:ruby>` run the `docx` package cannot emit, so a post-pack pass swaps the sentinel run for it; the `w:rubyPr` sizes are half-points of the 12pt body default, which is what LibreOffice's own export writes.
|
||||
- **Cell formulas** (`applyCellFormulas`, `formulaCellContent`): ODF puts the formula on the **cell**, so the pass walks `<table:table-cell` in the same document order `applyCellBlocks` does and writes `table:formula="ooow:…"` + the cached `office:value`. The `ooow:` is a namespace prefix, so `ensureOoowNamespace` declares it — undeclared, LibreOffice reads it as part of the formula. Word instead keeps a field *inside* the cell, so the DOCX side swaps the cell's first paragraph for one `SimpleField`. See `docs/architecture/tables.md`.
|
||||
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
|
||||
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
|
||||
@@ -63,4 +64,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
|
||||
`TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was),
|
||||
`TXBX_NUM` U+E01D (DOCX only: a numbering id a box's list has yet to be given),
|
||||
`IXE` U+E01E (an alphabetical-index entry), `BIB` U+E01F (a citation),
|
||||
`NOHYP` U+E020 (DOCX only: a paragraph whose w:pPr must gain w:suppressAutoHyphens).
|
||||
`NOHYP` U+E020 (DOCX only: a paragraph whose w:pPr must gain w:suppressAutoHyphens),
|
||||
`RBY` U+E021 (a ruby annotation).
|
||||
+42
-1
@@ -113,11 +113,19 @@ const EMU_PER_CM = 360000;
|
||||
// matching export/odt.ts's MTH.
|
||||
const MTH = '';
|
||||
|
||||
// Sentinel wrapping a ruby annotation's index inside its own run; the library has no
|
||||
// w:ruby, so applyRubyDocx swaps that run for one. U+E021, matching export/odt.ts.
|
||||
const RBY = '\uE021';
|
||||
|
||||
// Formulas collected while serializing, in document order (module-level like
|
||||
// docLangTag: inlineToRuns is reached from every block path without a collector).
|
||||
type FormulaDocx = { latex: string; display: boolean };
|
||||
let docFormulas: FormulaDocx[] = [];
|
||||
|
||||
// The ruby annotations, in the same document order their sentinels are met in.
|
||||
type RubyDocx = { base: string; text: string };
|
||||
let docRubies: RubyDocx[] = [];
|
||||
|
||||
// The sources cited, one per tag in document order — Word keeps them in a custom-XML
|
||||
// part and the CITATION fields only name the tag. Module-level like docFormulas.
|
||||
let docSources: BibSource[] = [];
|
||||
@@ -533,6 +541,10 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
|
||||
out.push(dateTimeRun(node));
|
||||
} else if (node.type === 'sequenceField') {
|
||||
out.push(sequenceField(node));
|
||||
} else if (node.type === 'ruby') {
|
||||
// The reading over its base text; applyRubyDocx swaps this run for <w:ruby>.
|
||||
docRubies.push({ base: String(node.attrs?.base ?? ''), text: String(node.attrs?.text ?? '') });
|
||||
out.push(new TextRun({ text: `${RBY}${docRubies.length - 1}${RBY}` }));
|
||||
} else if (node.type === 'indexEntry') {
|
||||
// Word's index entry: a hidden XE field, its term in the instruction. A key files
|
||||
// the term under it, "key:term", exactly as LibreOffice's text:key1 does.
|
||||
@@ -1109,6 +1121,34 @@ function applyFormulasDocx(bytes: Uint8Array, formulas: FormulaDocx[]): Uint8Arr
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// Post-pack pass: swap each sentinel run for a <w:ruby>. The sizes are half-points of
|
||||
// the body default (12pt): Word wants the base size and the reading's, and recomputes
|
||||
// neither — LibreOffice's own export writes the same four properties.
|
||||
function applyRubyDocx(bytes: Uint8Array, rubies: RubyDocx[]): Uint8Array {
|
||||
if (!rubies.length) return bytes;
|
||||
const files = unzipSync(bytes);
|
||||
const docBytes = files['word/document.xml'];
|
||||
if (!docBytes) return bytes;
|
||||
let xml = strFromU8(docBytes);
|
||||
xml = xml.replace(
|
||||
new RegExp(`<w:r\\b[^>]*?>(?:(?!</w:r>)[\\s\\S])*?${RBY}(\\d+)${RBY}(?:(?!</w:r>)[\\s\\S])*?</w:r>`, 'g'),
|
||||
(_m, idx: string) => {
|
||||
const r = rubies[Number(idx)];
|
||||
if (!r) return '';
|
||||
const run = (text: string, size = 0) =>
|
||||
`<w:r>${size ? `<w:rPr><w:sz w:val="${size}"/><w:szCs w:val="${size}"/></w:rPr>` : ''}`
|
||||
+ `<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
|
||||
return '<w:r><w:ruby><w:rubyPr><w:rubyAlign w:val="center"/><w:hps w:val="12"/>'
|
||||
+ `<w:hpsRaise w:val="24"/><w:hpsBaseText w:val="24"/><w:lid w:val="${docLangTag}"/></w:rubyPr>`
|
||||
+ `<w:rt>${run(r.text, 12)}</w:rt><w:rubyBase>${run(r.base)}</w:rubyBase></w:ruby></w:r>`;
|
||||
},
|
||||
);
|
||||
files['word/document.xml'] = strToU8(xml);
|
||||
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
|
||||
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// Post-pack pass: Word's mirror margins are a document setting (w:mirrorMargins in
|
||||
// word/settings.xml); the docx lib writes only the per-section w:pgMar, where left and
|
||||
// right are already the inner/outer pair the editor holds.
|
||||
@@ -1853,6 +1893,7 @@ export async function buildDocx(
|
||||
exportSheet = styles;
|
||||
exportSpacingModel = spacingModel;
|
||||
docFormulas = [];
|
||||
docRubies = [];
|
||||
docSources = [];
|
||||
const num = new Numbering();
|
||||
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
|
||||
@@ -2011,7 +2052,7 @@ export async function buildDocx(
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
|
||||
const cited = applyBibliographyDocx(packed, docSources, docCitationStyle(docJson));
|
||||
const cited = applyBibliographyDocx(applyRubyDocx(packed, docRubies), docSources, docCitationStyle(docJson));
|
||||
const withNotes = docNoteIds.size ? applyNotePrDocx(cited, notesSettings) : cited;
|
||||
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
|
||||
const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored);
|
||||
|
||||
+50
-2
@@ -191,6 +191,9 @@ const SEQ = '\uE01A';
|
||||
const IXE = '\uE01E';
|
||||
// Sentinel wrapping a citation's index (BIB{i}BIB); applyBibEntries resolves it.
|
||||
const BIB = '\uE01F';
|
||||
// Sentinel wrapping a ruby annotation's index (RBY{i}RBY); applyRuby rewrites it to
|
||||
// <text:ruby>. U+E021.
|
||||
const RBY = '\uE021';
|
||||
|
||||
// Sentinels for recorded revisions (trackChanges.ts): TCI{i}TCI brackets an insertion's
|
||||
// runs, TCD{i}TCD stands where a deletion's text was cut out. applyRevisions rewrites
|
||||
@@ -795,6 +798,50 @@ function applyBibEntries(odtBytes: Uint8Array, marks: BibExport[]): Uint8Array {
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// One ruby annotation, collected by replaceRuby: the base text and the reading over it.
|
||||
type RubyExport = { base: string; text: string };
|
||||
|
||||
function replaceRuby(node: TiptapNode, rubies: RubyExport[]): TiptapNode {
|
||||
if (!node.content?.length) return node;
|
||||
const content: TiptapNode[] = [];
|
||||
for (const child of node.content) {
|
||||
if (child.type === 'ruby') {
|
||||
const a = child.attrs ?? {};
|
||||
rubies.push({ base: String(a.base ?? ''), text: String(a.text ?? '') });
|
||||
content.push({ type: 'text', text: `${RBY}${rubies.length - 1}${RBY}`, marks: child.marks });
|
||||
continue;
|
||||
}
|
||||
content.push(replaceRuby(child, rubies));
|
||||
}
|
||||
return { ...node, content };
|
||||
}
|
||||
|
||||
// RBY sentinels → <text:ruby>. The alignment and the position ride a ruby-family
|
||||
// automatic style, which is where LibreOffice keeps them (probed: it writes the style
|
||||
// out again unchanged, adding only its own loext: mirror of the position).
|
||||
function applyRuby(odtBytes: Uint8Array, rubies: RubyExport[]): Uint8Array {
|
||||
if (!rubies.length) return odtBytes;
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
let content = strFromU8(contentBytes);
|
||||
content = content.replace(new RegExp(`${RBY}(\\d+)${RBY}`, 'g'), (_m, idx: string) => {
|
||||
const r = rubies[Number(idx)];
|
||||
if (!r) return '';
|
||||
return `<text:ruby text:style-name="${RUBY_STYLE}"><text:ruby-base>${escapeXml(r.base)}</text:ruby-base>`
|
||||
+ `<text:ruby-text>${escapeXml(r.text)}</text:ruby-text></text:ruby>`;
|
||||
});
|
||||
content = injectAutomaticStyles(
|
||||
content,
|
||||
`<style:style style:name="${RUBY_STYLE}" style:family="ruby">`
|
||||
+ '<style:ruby-properties style:ruby-align="center" style:ruby-position="above"/></style:style>',
|
||||
);
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
const RUBY_STYLE = 'Ru1';
|
||||
|
||||
// One cross-reference, collected by replaceCrossRefs and emitted by applyBookmarks.
|
||||
type CrossRefExport = { name: string; format: 'text' | 'page' };
|
||||
|
||||
@@ -4249,7 +4296,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const revisionList = new Map<string, RevisionExport>();
|
||||
const indexMarks: IndexEntryExport[] = [];
|
||||
const bibMarks: BibExport[] = [];
|
||||
const sentinels = replaceBibEntries(replaceIndexEntries(replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList), indexMarks), bibMarks);
|
||||
const rubies: RubyExport[] = [];
|
||||
const sentinels = replaceRuby(replaceBibEntries(replaceIndexEntries(replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList), indexMarks), bibMarks), rubies);
|
||||
const raw = markTextEffects(bakeListCharStyles(sentinels, styles));
|
||||
let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||||
let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||||
@@ -4453,7 +4501,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned));
|
||||
const withImages = applyImages(withBreaks, images);
|
||||
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
|
||||
const withSequences = applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks), bibMarks);
|
||||
const withSequences = applyRuby(applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks), bibMarks), rubies);
|
||||
const withFormulas = applyFormulas(withSequences, formulas);
|
||||
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
|
||||
const withColumns = applyColumns(withTextBoxes, columns);
|
||||
|
||||
@@ -40,6 +40,11 @@ const de: Messages = {
|
||||
urls: '',
|
||||
},
|
||||
},
|
||||
ruby: {
|
||||
title: 'Phonetische Zeichen',
|
||||
base: 'Basistext',
|
||||
reading: 'Ruby-Text',
|
||||
},
|
||||
autoText: {
|
||||
title: 'AutoText',
|
||||
empty: 'Noch keine Einträge. Text markieren und unten hinzufügen.',
|
||||
|
||||
@@ -41,6 +41,11 @@ const en = {
|
||||
urls: '',
|
||||
},
|
||||
},
|
||||
ruby: {
|
||||
title: 'Phonetic guide',
|
||||
base: 'Base text',
|
||||
reading: 'Ruby text',
|
||||
},
|
||||
autoText: {
|
||||
title: 'AutoText',
|
||||
empty: 'No entries yet. Select some text and add it below.',
|
||||
|
||||
@@ -24,13 +24,14 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
- **Image formats (`import/imageFormats.ts`, shared by both importers):** `<img>` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)).
|
||||
- **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder.
|
||||
- **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `<chart:chart>` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`.
|
||||
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (the thesis fixture lost every `[7]`-style reference and broke its lines one page short).
|
||||
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (a fixture measured against LibreOffice lost every `[7]`-style reference and broke its lines one page short).
|
||||
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
|
||||
- **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`.
|
||||
- **Page geometry is the *body's* master page**, not the one named `Standard`: the importer counts the blocks each `style:master-page-name` governs and re-reads geometry (and the app's own header/footer) from the winner. A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one.
|
||||
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
|
||||
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
|
||||
- **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
|
||||
- **Ruby**: `text:ruby` and `w:ruby` are one atom here (`ruby.ts`), so only the two texts are read — the base out of `text:ruby-base` / `w:rubyBase`, the reading out of `text:ruby-text` / `w:rt`. A file's own alignment and position are dropped; both products' defaults are what the exports write.
|
||||
- **Bibliography**: a `text:bibliography-mark`'s every `text:` attribute but the identifier and the type is a source field, whatever the file names it, so nothing is lost on a format we have no UI for. Word's `CITATION` names a tag only — the record comes from `customXml/item*.xml` (each is probed for a `b:Sources` root), and a tag no source declares still cites with the field's cached text. `BIBLIOGRAPHY` and `text:bibliography` both become the index node.
|
||||
- **Tracked changes**: ODF points at a registry, so `odfRevisions(body)` reads `<text:tracked-changes>` first and the body walk resolves each `text:change-start`/`-end` and `text:change` against it (the open insertions live in `Ctx` — a range may close in a later paragraph). A `text:change` is a deletion whose text is *only* in the registry: it is emitted there and then, marked. Word needs no registry — `w:ins`/`w:del` wrap their runs, whose text sits in `w:delText` rather than `w:t` for a deletion.
|
||||
- **Footnotes and endnotes** round-trip: `text:note` (ODF) and `word/footnotes.xml`/`endnotes.xml` (DOCX) become a `noteRef` anchor plus a `note` in the document-final `noteSection`, and the numbering configuration becomes `NoteSettings`. See `docs/architecture/notes.md`.
|
||||
|
||||
@@ -1320,6 +1320,14 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
if ((fieldShown || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
|
||||
else if (!skipResult()) pushText(child.textContent ?? '', marks);
|
||||
break;
|
||||
case 'ruby': {
|
||||
// Word nests both halves in runs of their own; the editor keeps them as one
|
||||
// atom, so only their text is read.
|
||||
const base = fcAll(child, 'rubyBase')[0]?.textContent ?? '';
|
||||
const reading = fcAll(child, 'rt')[0]?.textContent ?? '';
|
||||
if (base.trim() && !skipResult()) out.push({ type: 'ruby', attrs: { base: base.trim(), text: reading.trim() } });
|
||||
break;
|
||||
}
|
||||
case 'tab': if (!skipResult()) pushText('\t', marks); break;
|
||||
case 'br': out.push(child.getAttributeNS(W, 'type') === 'page' ? { type: PB_MARKER } : hardBreakNode(marks)); break;
|
||||
case 'cr': out.push(hardBreakNode(marks)); break;
|
||||
|
||||
@@ -1864,6 +1864,13 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
|
||||
walk(e, props, href || linkHref);
|
||||
continue;
|
||||
}
|
||||
case 'ruby': {
|
||||
// Both halves are elements of their own; the editor keeps them as one atom.
|
||||
const base = e.getElementsByTagNameNS(NS.text, 'ruby-base')[0]?.textContent ?? '';
|
||||
const reading = e.getElementsByTagNameNS(NS.text, 'ruby-text')[0]?.textContent ?? '';
|
||||
if (base.trim()) out.push({ type: 'ruby', attrs: { base: base.trim(), text: reading.trim() } });
|
||||
continue;
|
||||
}
|
||||
case 'note': {
|
||||
const note = convertNote(e, ctx, defaults);
|
||||
if (note) out.push(note);
|
||||
|
||||
@@ -184,6 +184,16 @@
|
||||
background: rgba(255, 145, 0, 0.8);
|
||||
}
|
||||
|
||||
/* A ruby annotation (ruby.ts). The reading sits above its base at half its size,
|
||||
which is what both word processors print and what `w:hps` says. */
|
||||
.paper .tiptap ruby {
|
||||
ruby-position: over;
|
||||
}
|
||||
.paper .tiptap ruby rt {
|
||||
font-size: 0.5em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The word being offered (wordCompletion.ts): after the caret, in the text's own
|
||||
font, greyed — it is not in the document until Enter takes it. */
|
||||
.paper .tiptap .word-completion {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// A ruby annotation is one element in both formats — a base and the reading over it —
|
||||
// so it travels as one atom. ODF writes <text:ruby> with a ruby-family style,
|
||||
// Word a <w:ruby> run.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
|
||||
type N = any;
|
||||
|
||||
const doc: N = {
|
||||
type: 'doc',
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Vor ' },
|
||||
{ type: 'ruby', attrs: { base: '漢字', text: 'かんじ' } },
|
||||
{ type: 'text', text: ' nach.' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
|
||||
|
||||
// The imported paragraph's inline children, flattened to what this test cares about.
|
||||
const inline = (r: N): N[] => ((r.content as N).content[0].content ?? []);
|
||||
|
||||
describe('ruby', () => {
|
||||
it('round-trips through ODF', async () => {
|
||||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
expect(xml).toContain('<text:ruby-base>漢字</text:ruby-base><text:ruby-text>かんじ</text:ruby-text>');
|
||||
expect(xml).toContain('style:family="ruby"');
|
||||
const back = inline(importOdt(bytes));
|
||||
expect(back.find((n) => n.type === 'ruby')?.attrs).toEqual({ base: '漢字', text: 'かんじ' });
|
||||
expect(back.map((n) => n.text ?? '').join('')).toBe('Vor nach.');
|
||||
});
|
||||
|
||||
it('round-trips through DOCX', async () => {
|
||||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||||
expect(xml).toContain('<w:rubyBase><w:r><w:t xml:space="preserve">漢字</w:t></w:r></w:rubyBase>');
|
||||
expect(xml).toContain('<w:rubyPr>');
|
||||
const back = inline(importDocx(bytes));
|
||||
expect(back.find((n) => n.type === 'ruby')?.attrs).toEqual({ base: '漢字', text: 'かんじ' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user