Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/cli/edit/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,11 @@ async function buildWholeParagraphContent(
const source = requireString(raw.markdown, index, "markdown");
let blocks: XmlNode[];
try {
blocks = await new MarkdownImport(document).blocks(source);
blocks = await new MarkdownImport(document).blocks(source, {
// Untracked: a `[^fnN]` copied from `read` keeps the note this entry's
// paragraph references instead of cloning it.
replacing: opts.track ? [] : [blockRef.node],
});
} catch (error) {
if (error instanceof MarkdownImportError) {
throw new EntryError(
Expand Down
27 changes: 22 additions & 5 deletions src/cli/edit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,11 @@ async function commitBlockEdit(
},
));
} else if (opts.spec.kind === "markdown") {
const resolved = await resolveMarkdownBlocks(document, opts.spec);
const resolved = await resolveMarkdownBlocks(
document,
opts.spec,
track ? [] : [blockRef.node],
);
if (typeof resolved === "number") return resolved;
({ node: resultNode, resolvedAuthors } = edit.paragraph(
blockRef,
Expand Down Expand Up @@ -575,9 +579,18 @@ async function commitRangeEdit(

if (opts.dryRun) return respondDryRun(opts);

const track = resolveTracked(document, opts.trackFlag);
let spec: ParagraphContentSpec;
if (opts.spec.kind === "markdown") {
const resolved = await resolveMarkdownBlocks(document, opts.spec);
const replacing = rangeRef.parent.slice(
rangeRef.startIndex,
rangeRef.endIndex + 1,
);
const resolved = await resolveMarkdownBlocks(
document,
opts.spec,
track ? [] : replacing,
);
if (typeof resolved === "number") return resolved;
spec = resolved;
} else if (opts.spec.kind === "text" || opts.spec.kind === "runs") {
Expand All @@ -586,7 +599,6 @@ async function commitRangeEdit(
return fail("USAGE", "Unsupported edit spec for range locator");
}

const track = resolveTracked(document, opts.trackFlag);
let resolvedAuthors: string[];
try {
resolvedAuthors = new Edit(document).range(rangeRef, spec, {
Expand Down Expand Up @@ -1183,15 +1195,20 @@ async function loadMarkdownFile(path: string): Promise<string | number> {
/** Resolve a `markdown` spec into a `markdown-blocks` spec by parsing the
* source against the now-open document. The lens registers footnote bodies,
* mints image rels, and provisions any styles the source references — all on
* `document` — before returning the splice-ready blocks. */
* `document` — before returning the splice-ready blocks. `replacing` is the
* block(s) an UNTRACKED edit replaces (empty under tracking), so a `[^fnN]`
* copied from `read` keeps its note instead of cloning it. */
async function resolveMarkdownBlocks(
document: Document,
spec: Extract<EditSpec, { kind: "markdown" }>,
replacing: readonly XmlNode[],
): Promise<
Extract<ParagraphContentSpec, { kind: "markdown-blocks" }> | number
> {
try {
const blocks = await new MarkdownImport(document).blocks(spec.source);
const blocks = await new MarkdownImport(document).blocks(spec.source, {
replacing,
});
return {
kind: "markdown-blocks",
blocks,
Expand Down
9 changes: 8 additions & 1 deletion src/cli/read/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1608,7 +1608,14 @@ function renderRuns(
else ctx.referencedEndnoteIds.add(run.id);
out += `[^${run.id}]`;
} else if (run.type === "chart") {
out += `\`[${run.kind}]\``;
// A chart / SmartArt / shape has no Markdown form. Its marker is a
// `docx:` HINT, not content: it used to print as inline code
// (`` `[chart]` ``), so a read line copied back into `edit` kept the
// chart (lifted and re-attached) AND wrote the literal text "[chart]"
// — and its 9 characters skewed every span read off the line, since the
// object is zero-width in the offset model. The importer drops the
// comment (a leading one is split off by `reparseHintLedLines`).
out += formatNote(run.kind, []);
}
// A text box contributes nothing inline: its story renders as its own
// block right after the anchor (see `renderTextBoxes`).
Expand Down
47 changes: 47 additions & 0 deletions src/core/ast/document/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ export class NotesView {
return undefined;
}

/** A real note (not Word's `w:type` separator boilerplate) by numeric id. */
findUserNote(numericId: string): XmlNode | undefined {
const found = this.findByNumericId(numericId);
if (!found || found.node.getAttribute("w:type")) return undefined;
return found.node;
}

/** Copy note `numericId` under a fresh id (placed right after the original)
* and return the new id — what Word does when text holding a note reference
* is pasted. Markers whose ids must stay document-unique (bookmarks, comment
* ranges + references, `w14` paragraph ids) are dropped from the copy, so the
* clone can't collide with the original. The caller has already checked the
* note exists (`findUserNote`). */
cloneNote(numericId: string): string {
const found = this.findByNumericId(numericId);
if (!found) throw new Error(`Internal: no note ${numericId} to clone`);
const copy = found.node.clone();
const id = this.nextId();
copy.setAttribute("w:id", id);
stripUniqueMarkers(copy);
found.parent.splice(found.parent.indexOf(found.node) + 1, 0, copy);
return id;
}

/** Lazily provision the two character/paragraph styles a note relies on.
* The baseline catalog defines both — these calls just register the style
* nodes in `styles.xml` if not already present, which avoids Word's
Expand All @@ -178,3 +202,26 @@ export class NotesView {
return XmlNode.findRoot(this.tree, config.rootTag);
}
}

/** Drop, in place, every marker under `node` whose id must be unique across the
* document — so a cloned note body can't duplicate one. */
function stripUniqueMarkers(node: XmlNode): void {
delete node.attributes["w14:paraId"];
delete node.attributes["w14:textId"];
node.children = node.children.filter((child) => !isUniqueMarker(child));
for (const child of node.children) stripUniqueMarkers(child);
}

function isUniqueMarker(node: XmlNode): boolean {
if (UNIQUE_MARKER_TAGS.has(node.tag)) return true;
return (
node.tag === "w:r" && node.findChild("w:commentReference") !== undefined
);
}

const UNIQUE_MARKER_TAGS: ReadonlySet<string> = new Set([
"w:bookmarkStart",
"w:bookmarkEnd",
"w:commentRangeStart",
"w:commentRangeEnd",
]);
10 changes: 10 additions & 0 deletions src/core/markdown/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Files behind the `@core/markdown` barrel ([index.ts](index.ts)):
- [inline.tsx](inline.tsx) — phrasing walker (`walkInline`). Returns `<w:p>`-child siblings: a mix of `<w:r>`, `<w:hyperlink>`, `<m:oMath>`, `<w:ins>`, `<w:del>`. Threads an immutable `InlineFormat` through nested strong / em / delete / inlineCode / bracketed-span wrappers.
- [inline-surgery.ts](inline-surgery.ts) — `remarkInlineSurgery` plugin. Post-parse, rewrites each phrasing-children array with two reducers: text-marker surgery gathers CriticMarkup (`{++…++}` / `{--…--}`) and legacy Pandoc spans (`[text]{.class key="value"}`); **HTML-element surgery (`gatherHtmlSpans`)** re-pairs the `<span>` / `<mark>` / `<sup>` / `<sub>` / `<u>` tags `read` now emits (remark leaves inline HTML as FLAT, unpaired `html` tokens — open tag, mdast content, close tag as separate siblings — so we re-pair them, the same stack-reduce as the text markers). Both feed `bracketedSpan` (etc.) parent nodes the inline walker dispatches on. Each tag's attributes are parsed with the project's `XmlNode.parse` (fast-xml-parser), not a bespoke regex. Scanning whole sibling arrays (not single `text` nodes) is what lets a marker straddle inline formatting.
- [read-hint-labels.ts](read-hint-labels.ts) — `remarkReadHintLabels` plugin. Drops the footnote-shaped READ annotations (`[^tcN]` / `[^cN]` + their definitions) on import — see "Footnote-shaped read annotations" below.
- [note-reference-labels.ts](note-reference-labels.ts) — `remarkNoteReferenceLabels` plugin. A `[^fnN]`/`[^enN]` with no definition in the source addresses the document's EXISTING note — see "Read-copied note references" below.
- [hint-led-lines.ts](hint-led-lines.ts) — `reparseHintLedLines`. Re-parses the text after a LEADING `docx:` hint comment so CommonMark's HTML-block rule can't swallow the line — see "Inline hints that lead a line" below.
- [escape.ts](escape.ts) — `inlineEscapeMask`, the parser-driven escape mask `read` applies (see "Metacharacters are escaped" below).
- [display-math.ts](display-math.ts) — `promoteDisplayMath`. Post-parse, turns a top-level paragraph that is ONLY a one-line `$$…$$` (whitespace and `<!-- … -->` comments aside) into a display `math` block. `read` prints a display equation exactly that way, and remark-math alone parses it as INLINE math, so pasting a read line back into `edit`/`insert`/`create` demoted the block to inline (a multi-line `\begin{gathered}` block collapsed into one inline `<m:oMath>`). A `$$…$$` beside other text stays inline. A paragraph made ONLY of CriticMarkup each wrapping one `$$…$$` — how `read --current` prints a tracked display-equation replace, `{--$$old$$--}{++$$new$$++}` — can't become a block (a block can't sit inside `<w:ins>`), so `markTrackedDisplayMath` flags each `inlineMath` `data.display` and the inline walker emits `<m:oMathPara>` inside its `<w:del>`/`<w:ins>`, the shape a tracked display replace writes.
- [errors.ts](errors.ts) — `MarkdownImportError` (code is a strict subset of `cli/respond.ts`'s `ErrorCode`).
Expand Down Expand Up @@ -37,6 +39,14 @@ When `<w:trackRevisions/>` is on, `criticInsert` wraps its (now possibly multi-r

`remarkReadHintLabels` ([read-hint-labels.ts](read-hint-labels.ts)) has two halves, because GFM only parses `[^x]` as a reference when a `[^x]:` definition exists: a micromark TEXT construct (`add: "before"`, so it wins over GFM's footnote call) claims every `[^tcN]`/`[^cN]` reference — defined or not — as a `readHintLabel` node; a transformer, ordered BEFORE `remarkInlineSurgery`, removes those nodes and every hint-named `footnoteDefinition`, re-merging the text around a removed label so the surgery tokenizes `{++45++}[^tc1]` as an insertion. Being a parser construct it is escape- and code-aware for free (`` `[^tc0]` `` and `\[^tc0]` stay literal), and `escape.ts` registers the plugin too, so `read` escapes a LITERAL `[^tc0]` in document text (`\[\^tc0\]`) and it round-trips instead of vanishing. Matching is case-insensitive (GFM normalizes identifiers) and exact-shape only: `[^note]`, `[^fn1]`, `[^cat]` stay real footnotes. A user's own footnote literally named `[^c1]`/`[^tc1]` is dropped — name it anything else. `[^revN]` needs no rule: `track-changes list` emits `revN` group ids, but `read` never prints them as labels.

## Read-copied note references (`[^fnN]` / `[^enN]`) address the existing note

`read` prints a real note reference as `[^fnN]` inline and its body as a `[^fnN]: …` definition at the END of the read. An agent copying one line back into `edit`/`insert` carries the label without the definition, and GFM only parses `[^x]` when a definition exists — so it used to land as the literal characters `[^fn1]` (and the note was lost on the next accept). `remarkNoteReferenceLabels` claims every `[^fn<digits>]`/`[^en<digits>]` with a micromark text construct (escape/code-aware; `escape.ts` registers it, so a LITERAL `[^fn1]` in document text reads as `\[\^fn1\]` and round-trips), then resolves each: a label the source also DEFINES goes back to an ordinary `footnoteReference` (a whole read pasted into `create` mints notes exactly as before); otherwise it becomes an `existingNoteReference` the inline walker resolves against the target document. **Reuse vs clone:** OOXML allows exactly one reference per note (Word "repairs" N:1 as corruption), so the walker REUSES note N only when `blocks(source, { replacing })` names the block(s) an UNTRACKED edit replaces and one of them references N (the old reference leaves with the old block); everywhere else — an insert, a second citation, any tracked edit (whose old reference stays behind in its `<w:del>`) — it clones the note (`NotesView.cloneNote`, dropping bookmarks/comment markers/`w14` ids from the copy), which is Word's own paste behavior. Accept then keeps the clone and reject the original, each with one live reference. A label naming no note is a USAGE error, never literal text. `edit` (single, range, batch) is the only caller that passes `replacing`.

## Inline hints that lead a line

A `docx:` hint can sit inline, at the position of something Markdown can't show — `<!-- docx:chart -->` (also `docx:smartart`/`docx:shape`/`docx:drawing`) marks a non-picture drawing, zero-width like the drawing itself is in the offset model. Mid-line it's an inline html node the walker drops. But CommonMark opens an HTML BLOCK at any line that begins with `<!--` and runs it to the end of that line, so `<!-- docx:chart -->Figure 2 <!-- p1 -->` parsed as one dropped block and took the caption with it. `reparseHintLedLines` (called at the end of `parseToMdast`) replaces an html block that starts with `docx:` comments and still holds non-comment text by the parse of that remainder; a comments-only line (`<!-- docx:p p1 page-break -->`) stays a dropped block, and an author's own non-`docx:` comment keeps CommonMark's meaning. The drawing itself is never in the Markdown — `edit` lifts and re-attaches it — so copying the line back keeps it once.

## Run-formatting encoding: hybrid HTML (the read↔import contract)

Run-level formatting with no native markdown syntax is emitted as **HTML a markdown reader actually renders** — not Pandoc `[text]{…}` spans, which show literal brackets in GitHub / VS Code / Obsidian. `read --markdown` emits it (via `htmlFormattingWrappers` in [cli/read/markdown.ts](../../cli/read/markdown.ts)) and the import walker parses it back (via `gatherHtmlSpans` in [inline-surgery.ts](inline-surgery.ts), which converts each tag to a `bracketedSpan` the inline walker already overlays). **The tag → property mapping is the contract; keep `htmlFormattingWrappers` and `gatherHtmlSpans`/`applyCssStyle` in sync.** `read --ast` is the lossless format; markdown is the human/agent comprehension format.
Expand Down
6 changes: 5 additions & 1 deletion src/core/markdown/escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkParse from "remark-parse";
import { unified } from "unified";
import { remarkNoteReferenceLabels } from "./note-reference-labels";
import { remarkReadHintLabels } from "./read-hint-labels";

// A node's source-offset span. Every mdast node carries this, but the union also
Expand All @@ -20,11 +21,14 @@ type Positioned = {
// `remarkReadHintLabels` IS included: its parser construct claims a literal
// `[^tcN]`/`[^cN]` in run text (the importer would drop it as a read
// annotation), so the mask escapes it and the text survives the round-trip.
// `remarkNoteReferenceLabels` likewise claims a literal `[^fn1]`/`[^en1]` (the
// importer would resolve it to the document's note), so it's escaped too.
const rawParser = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkReadHintLabels);
.use(remarkReadHintLabels)
.use(remarkNoteReferenceLabels);

// Every char CommonMark lets a backslash escape. A `\` before one of these is an
// escape; escaping anything else (a letter, a space) is a no-op the parser drops.
Expand Down
66 changes: 66 additions & 0 deletions src/core/markdown/hint-led-lines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Root, RootContent } from "mdast";

/** Re-parse the content after a LEADING `docx:` hint comment.
*
* CommonMark opens an HTML block at any line that begins with `<!--` and runs
* it to the end of the line holding `-->`, so a line `read` prints with an
* inline zero-width hint first — `<!-- docx:chart -->Figure 2: sales <!-- p1 -->`
* — parsed as ONE html block. The importer drops html blocks (every comment is
* a hint, never content), which silently took the text after the hint with it:
* a copied line handed back to `edit` replaced the paragraph with nothing.
*
* So an html block that STARTS with one or more `docx:` comments and still
* holds text after them is replaced by the parse of that remainder — the hint
* goes, the content stays, exactly as if the hint were mid-line (where it is
* already an inline html node the walker drops). Only `docx:` comments qualify:
* an author's own `<!-- … -->` keeps CommonMark's meaning. A line that is ONLY
* comments (`<!-- docx:p p1 page-break -->`) is left as the dropped html block. */
export function reparseHintLedLines(
tree: Root,
parse: (source: string) => Root,
): void {
reparseContainer(tree, parse);
}

function reparseContainer(
node: { children?: unknown[] },
parse: (source: string) => Root,
): void {
if (!Array.isArray(node.children)) return;
const children = node.children as RootContent[];
const out: RootContent[] = [];
for (const child of children) {
const remainder =
child.type === "html" ? hintLedRemainder(child.value) : null;
if (remainder === null) {
if (BLOCK_CONTAINERS.has(child.type)) {
reparseContainer(child as { children?: unknown[] }, parse);
}
out.push(child);
continue;
}
out.push(...parse(remainder).children);
}
node.children = out;
}

/** Block-level nodes whose children are blocks (where an html BLOCK can sit). */
const BLOCK_CONTAINERS: ReadonlySet<string> = new Set([
"blockquote",
"listItem",
"footnoteDefinition",
"list",
]);

const LEADING_HINTS = /^\s*(?:<!--\s*docx:[\s\S]*?-->[ \t]*)+/;
const ANY_COMMENT = /<!--[\s\S]*?-->/g;

/** The source after the leading `docx:` comments, or null when the block
* doesn't start with one or holds nothing but comments after it. */
function hintLedRemainder(value: string): string | null {
const match = value.match(LEADING_HINTS);
if (!match) return null;
const remainder = value.slice(match[0].length);
if (remainder.replace(ANY_COMMENT, "").trim() === "") return null;
return remainder;
}
Loading
Loading