From bab2ffcfb83afa0611d61f9e970b96a06b2610bb Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 3 Nov 2026 23:36:47 +0100 Subject: [PATCH] Group a line's runs in one home, part a line as its first fallback, and trim each portable line by its own runs --- CHANGELOG.md | 5 +- README.md | 2 +- docs/decisions.md | 5 +- spec/flavour.md | 13 +- src/markdown/emit/adf-to-markdown.test.ts | 9 ++ src/markdown/emit/inline-line.ts | 139 ++++-------------- src/markdown/emit/inline-runs.ts | 68 +++++++++ src/markdown/emit/line-escaping.ts | 4 +- src/markdown/emit/unspellable-runs.ts | 15 +- src/markdown/mark-spellings.ts | 18 +-- .../portable/adf-to-portable-markdown.test.ts | 18 ++- .../portable/inline-reduction.test.ts | 22 +-- src/markdown/portable/inline-reduction.ts | 93 ++++++++---- 13 files changed, 222 insertions(+), 189 deletions(-) create mode 100644 src/markdown/emit/inline-runs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9543c1e..606abdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,8 +39,9 @@ span: ``**Run `npm ci` first**``. `adfToLosslessMarkdown` also writes those three once across mentions, emoji, dates, statuses, inline cards and inline media, as `**Ping !adf:mention[@A]{id=x} now**`, and a link once across mentions, emoji, dates, statuses - and inline cards. The markdown reads back to the same ADF as before. Where the joined line would - not, the writers write the line as before. + and inline cards. The markdown reads back to the same ADF as before, except that + `adfToPortableMarkdown` drops whitespace beside a hard break a mark crosses, where CommonMark + strips it. Where the joined line would not read back, the writers write the line as before. - Every reader keeps the URL and title of an image inside a link's text, where it dropped them: after the link comes `(image)`, which links to the image, so `[look at ![the moon](moon.png "Full") tonight](/moon)` reads as diff --git a/README.md b/README.md index 09c8791..2b4234e 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,7 @@ saving what this pair read replaces mentions, attachments and macros with text. read back wrong after 32 tries drops every `em`, `strike` and `strong` at once. Code text keeps no mark but `code` and `link`. - A newline in text is a hard break and in an expand's title a space, edge whitespace outside a - link or code span is trimmed, carriage returns and null characters are removed, and an empty + link or code span, or beside a hard break a link crosses, is trimmed, carriage returns and null characters are removed, and an empty paragraph drops. - A text node's `content` drops: Atlassian's schema forbids it, so no rendered document shows it. - Adjacent lists of a kind alternate markers, `- ` and `* `, `1.` and `1)`, since CommonMark diff --git a/docs/decisions.md b/docs/decisions.md index 1098e3a..d6cb4d6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -719,8 +719,9 @@ and every ruling stays in the history. Each restructure runs one rung higher; pa the maintainer decides. A ruling judges the code, never the behaviour: item 91's spanning stays, since the paragraphs it spells, such as bold around a code span, occur in documents all the time (2026-11-03). The maintainer's call on how: refactor first, ruled alone (item 166), then make spanning part of -grouping, with the span rule in one home. `runSpans`, beside `insideRun`, says which node a run passes. `inlineRuns` ends -a run at its last holder, and the reducer asks that same grouping through `runsSharedWithNext`. `withoutOpeningLinkCode` +grouping, with the span rule in one home. `inlineRuns` in `emit/inline-runs.ts` groups a line, `runCrosses` says which +node a run passes, and a run ends at its last holder. Parting is a line's first fallback, so each pass walks once. The reducer +judges the spanned line, then the parted line, each trimmed by its own runs through that same grouping. `withoutOpeningLinkCode` stays on `insideRun` alone, since it answers the parted line. A refactor that merges as its own chunk took its own ruling, so the chunk's later rulings read against that merge, the commit `larv-review ruling-base` prints. A restructure past a pass's round cap still goes unreviewed, so the PR summary names it. diff --git a/spec/flavour.md b/spec/flavour.md index 3cb886c..b48b1eb 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -564,12 +564,13 @@ marks array in order, outermost first: `_!adf:underline[x]_` gives marks `[em, u the array, not a set — and opens each spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run breaks at every node the emitter carries, so no -emitted carry sits inside a mark spelling. An `em`, `strike`, `strong` or `link` run also spans a -node between two of its nodes when that node cannot hold the run's mark. It does so only where the -spanned line reads back to the same nodes; otherwise the run breaks there. So `_One\` then `Two_` -is one `em` run over two texts, the hard break between them unmarked, and -``**Run `npm ci` first**`` is one `strong` run around unmarked code. `adfToPortableMarkdown` spans -hard breaks and code spans the same way. +emitted carry sits inside a mark spelling. An `em`, `strike`, `strong` or `link` run also crosses a +node between two of its nodes when that node cannot hold the run's mark, unless the node holds an +empty `marks`, which no spelling inside a mark holds. So `_One\` then `Two_` is one `em` run over two +texts, the hard break between them unmarked, and ``**Run `npm ci` first**`` is one `strong` run +around unmarked code. A line that needs any fallback, a carry or a dropped mark, breaks every run on +it at those nodes instead. `adfToPortableMarkdown` crosses hard breaks and code spans the same way, +and drops whitespace beside a hard break a run crosses, where CommonMark strips it. An inline node whose marks no nesting spells — a node holding a mark Atlassian's schema withholds from it, a mark type the first list above does not hold, an attrs key its spelling does not list, a value that is not the diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 2c9c707..029bbb3 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -927,6 +927,15 @@ test('spells an emphasis or link run once across the hard breaks between two nod assert.equal(readsBack(document(paragraph({ text: 'x', type: 'text' }, marked('.', em), hardBreak, marked('b', em)))), 'x!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\".\\",\\"type\\":\\"text\\"}"}\\\n_b_\n') }) +test('parts every run on a line that needs a fallback, whichever run needs it', () => { + const em: AdfMark = { type: 'em' } + const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) + assert.equal( + readsBack(document(paragraph({ text: 'x', type: 'text' }, marked('.', { type: 'strong' }), { text: 'y ', type: 'text' }, marked('a', em), { type: 'hardBreak' }, marked('b', em)))), + 'x!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\".\\",\\"type\\":\\"text\\"}"}y _a_\\\n_b_\n', + ) +}) + test('spells an emphasis run once across the code spans, mentions, dates and inline media between two nodes holding the mark', () => { const strong: AdfMark = { type: 'strong' } const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 3017857..0fa7ee2 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -1,13 +1,14 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts' +import type { InlineRun, RunGrouping, RunNode } from './inline-runs.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinition, TitleEscapes } from '../commonmark/link-syntax.ts' import type { RunFallback } from './unspellable-runs.ts' import type { WrittenFlavour } from '../flavour.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' -import { commonMarkLink, insideRun, isEmphasis, linkHref, markSpelling, runSpans, spellMarkAttributes, type RunNode } from '../mark-spellings.ts' -import { directivePrefix, slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts' +import { carries, inlineRuns } from './inline-runs.ts' +import { commonMarkLink, isEmphasis, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims } from '../flavour.ts' @@ -18,6 +19,7 @@ import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/do import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts' +import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts' import { spellInlineNodeAttributes } from '../directive/inline-spelling.ts' import { spellTextDirective } from '../directive/text.ts' @@ -28,26 +30,20 @@ type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } | type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link' -type InlineContext = { +type InlineContext = RunGrouping & { atBlockEnd: boolean bracketed: boolean - carried: ReadonlySet container: LineContainer flavour: WrittenFlavour lineNodes: readonly AdfNode[] openingLinkAsDirective: boolean path: ConvertErrorPath - spans: boolean spansLines: boolean } -type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: RunNode[] } | { index: number; kind: 'plain'; node: AdfNode } - type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } -type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } - -type RunGrouping = { carried: ReadonlySet; spans: boolean } +type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean; parted: boolean } // docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark. export const readBackPasses = 32 @@ -66,9 +62,9 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve return success(emitted.value.openingLinkAsDirective) } -export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { - const fallbacks = withheldMarkFallbacks(nodes, 'portable') - const emission = lineSegments(nodes, container, path, fallbacks, false) +export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, { parted }: Pick): Result { + const fallbacks = { ...withheldMarkFallbacks(nodes, 'portable'), parted } + const emission = lineSegments(nodes, container, path, fallbacks) if (!emission.ok) return emission if (fallbacks.carried.size > 0 || emission.value.carries !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path) const verdict = lineVerdict(emission.value.segments, container, 'portable', { kind: 'drop', nodes }) @@ -97,8 +93,8 @@ export function tryImageLine(alt: string | undefined, url: string, flavour: Writ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { const fallbacks = withheldMarkFallbacks(nodes, flavour) - // Terminates because takeFallback refuses a pass that took no new fallback. - for (let pass = 1; ; pass += 1) { + // Terminates because takeFallback refuses a pass that took no new fallback. Pass 0 only parts the line, so `readBackPasses` counts what a parted line takes. + for (let pass = 0; ; pass += 1) { const attempt = linePass(nodes, container, path, fallbacks) if (!attempt.ok) return attempt const { fallback, line, segments } = attempt.value @@ -109,20 +105,8 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } } -// spec/flavour.md, Marks: a pass writes the spanned line where it needs no fallback; otherwise it writes the parted line and takes that line's fallbacks. export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - const spanned = lineAttempt(nodes, container, path, fallbacks, true) - return spanned.ok && writesSpanned(spanned.value, fallbacks.flavour) ? spanned : lineAttempt(nodes, container, path, fallbacks, false) -} - -function writesSpanned({ fallback, segments }: LineAttempt, flavour: WrittenFlavour): boolean { - if (fallback !== undefined) return false - // A span can leave whitespace at a line edge, which only `!adf:text` spells. - return flavourClaims[flavour].directives || !segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix)) -} - -function lineAttempt(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result { - const emission = lineSegments(nodes, container, path, fallbacks, spans) + const emission = lineSegments(nodes, container, path, fallbacks) if (!emission.ok) return emission const { carries, segments } = emission.value return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour)) @@ -139,10 +123,15 @@ export function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: Writte const taken = takenMarkTypes(node) if (taken !== undefined && nodeMarks(node).some((mark) => !taken.includes(mark.type))) carried.add(index) } - return { carried, flavour, openingLinkAsDirective: false } + return { carried, flavour, openingLinkAsDirective: false, parted: false } } export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result { + // spec/flavour.md, Marks: a line needing a fallback first parts its runs, and takes what the parted line still needs. + if (!fallbacks.parted) { + fallbacks.parted = true + return success(null) + } if (fallback === 'opening-link') { if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path) fallbacks.openingLinkAsDirective = true @@ -154,12 +143,12 @@ export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: return success(null) } -function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result { - const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spans, spansLines: container === 'paragraph' } +function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { + const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spansLines: container === 'paragraph' } const emission = emitRun(nodes.map((node) => ({ node, outer: 0 })), 0, 0, context) if (!emission.ok) return emission if (emission.value.carries !== undefined) return emission - return success({ segments: spellEdgeWhitespace(emission.value.segments) }) + return success({ segments: spellEdgeWhitespace(emission.value.segments, fallbacks.flavour) }) } function attemptLine(segments: InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt { @@ -177,8 +166,9 @@ function lineVerdict(segments: readonly InlineSegment[], container: LineContaine return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] } } -// A space or tab CommonMark strips at a line edge rides !adf:text{text="…"}. -function spellEdgeWhitespace(segments: readonly InlineSegment[]): InlineSegment[] { +// A space or tab CommonMark strips at a line edge rides !adf:text{text="…"}. A flavour without directives leaves it raw, since the reducer trims each line by its own runs. +function spellEdgeWhitespace(segments: InlineSegment[], flavour: WrittenFlavour): InlineSegment[] { + if (!flavourClaims[flavour].directives) return segments const spelled: InlineSegment[] = [] for (const [index, segment] of segments.entries()) { const previous = segments[index - 1] @@ -244,56 +234,6 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c return success(carry === undefined ? { segments } : { carries: [carry, ...others] }) } -function inlineRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping): InlineRun[] { - const runs: InlineRun[] = [] - let next = 0 - for (const [offset, { node, outer }] of nodes.entries()) { - if (offset < next) continue - const index = firstIndex + offset - // A carried node spells its marks inside the carry. - const mark = carries(node, grouping.carried, index) ? undefined : nodeMarks(node)[outer] - if (mark === undefined) { - runs.push({ index, kind: 'plain', node }) - continue - } - const inside = nodesInRun(nodes, offset, mark, firstIndex, grouping) - runs.push({ index, kind: 'marked', mark, nodes: inside }) - next = offset + inside.length - } - return runs -} - -// The run of `mark` opening at `first` ends at the last node holding the mark, so the nodes it spans past that one stay outside. -function nodesInRun(nodes: readonly RunNode[], first: number, mark: AdfMark, firstIndex: number, { carried, spans }: RunGrouping): RunNode[] { - const inside: RunNode[] = [] - let holders = 0 - for (let offset = first; offset < nodes.length; offset += 1) { - const runNode = nodes[offset] - if (runNode === undefined || carries(runNode.node, carried, firstIndex + offset)) break - const held = insideRun(runNode, mark) - if (held === undefined && !(spans && runSpans(runNode.node, mark))) break - inside.push(held ?? runNode) - if (held !== undefined) holders = inside.length - } - return inside.slice(0, holders) -} - -// For each leaf but the last, how many runs of the spanned line hold both it and the next leaf. -export function runsSharedWithNext(leaves: readonly AdfNode[]): number[] { - const shared = leaves.map(() => 0) - countSharedRuns(leaves.map((node) => ({ node, outer: 0 })), 0, 0, shared) - return shared -} - -function countSharedRuns(nodes: readonly RunNode[], depth: number, firstIndex: number, shared: number[]): void { - if (depth > largestNesting) return - for (const run of inlineRuns(nodes, firstIndex, { carried: new Set(), spans: true })) { - if (run.kind === 'plain') continue - for (let index = run.index; index < run.index + run.nodes.length - 1; index += 1) shared[index] = (shared[index] ?? 0) + 1 - countSharedRuns(run.nodes, depth + 1, run.index, shared) - } -} - function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet): boolean { if (previous?.kind !== 'plain' || run.kind !== 'plain') return false if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false @@ -304,16 +244,6 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath { return [...context.path, 'content', index] } -// Decides the text-node and slot-fault carries up front, `withheldMarkFallbacks` the withheld-mark ones, so a line holding many of them is emitted once. -function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean { - if (carried.has(index)) return true - if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === '' - const model = inlineNodeModel(node.type) - if (model === undefined) return true - const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute] - return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined -} - // Builds the node's path only to refuse it: a path copies every level above, and a line may hold a million leaves. function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result { if (carries(node, context.carried, index)) { @@ -366,7 +296,7 @@ function textSegments(node: AdfNode, context: InlineContext): InlineSegment[] | function emitMarkedRun(nodes: readonly RunNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { const range: NodeRange = { first: index, last: index + nodes.length - 1 } - if (mark.type === 'backgroundColor' && flavourClaims[context.flavour].highlights) return emitHighlight(nodes, depth, range, context) + if (mark.type === 'backgroundColor' && flavourClaims[context.flavour].highlights) return emitHighlight(nodes, depth, { ...range, mark }, context) const spelling = markSpelling(mark.type) const attributes = spelling === undefined ? undefined : spellMarkAttributes(mark, spelling) if (spelling === undefined || attributes === undefined) return carriedRun(range, context) @@ -374,7 +304,7 @@ function emitMarkedRun(nodes: readonly RunNode[], mark: AdfMark, depth: number, const spans = codeSpans(nodes) return spans === undefined ? carriedRun(range, context) : success({ segments: [syntax(spans)] }) } - if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) + if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, { ...range, mark }, context) const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined if (link !== undefined) return link const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) @@ -383,24 +313,19 @@ function emitMarkedRun(nodes: readonly RunNode[], mark: AdfMark, depth: number, return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] }) } -function emitEmphasis(nodes: readonly RunNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result { - const inner = emitRun(nodes, depth + 1, range.first, context) +function emitEmphasis(nodes: readonly RunNode[], spelling: string, depth: number, run: MarkRun, context: InlineContext): Result { + const inner = emitRun(nodes, depth + 1, run.first, context) if (!inner.ok) return inner if (inner.value.carries !== undefined) return inner - const spelled = spellEdgeWhitespace(inner.value.segments) + const spelled = spellEdgeWhitespace(inner.value.segments, context.flavour) return success({ - segments: [ - { emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling }, - ...spelled, - { emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling }, - ], + segments: [{ emphasis: 'open', escaping: 'none', nodes: run, text: spelling }, ...spelled, { emphasis: 'close', escaping: 'none', nodes: run, text: spelling }], }) } -function emitHighlight(nodes: readonly RunNode[], depth: number, range: NodeRange, context: InlineContext): Result { - const inner = emitRun(nodes, depth + 1, range.first, context) +function emitHighlight(nodes: readonly RunNode[], depth: number, run: MarkRun, context: InlineContext): Result { + const inner = emitRun(nodes, depth + 1, run.first, context) if (!inner.ok || inner.value.carries !== undefined) return inner - const run = { ...range, depth } return success({ segments: [{ escaping: 'none', highlight: 'open', nodes: run, text: highlightDelimiter }, ...inner.value.segments, { escaping: 'none', highlight: 'close', nodes: run, text: highlightDelimiter }], }) diff --git a/src/markdown/emit/inline-runs.ts b/src/markdown/emit/inline-runs.ts new file mode 100644 index 0000000..39245e6 --- /dev/null +++ b/src/markdown/emit/inline-runs.ts @@ -0,0 +1,68 @@ +import type { AdfMark, AdfNode } from '../../adf/document.ts' +import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts' +import { markSpelling } from '../mark-spellings.ts' +import { slotFault } from '../directive/syntax.ts' + +export type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: RunNode[] } | { index: number; kind: 'plain'; node: AdfNode } + +// `parted` keeps a run from crossing any node. +export type RunGrouping = { carried: ReadonlySet; parted: boolean } + +// An inline node inside the runs of its first `outer` marks. A node a run crosses holds no mark of that run, so its `outer` stays. +export type RunNode = { node: AdfNode; outer: number } + +export const partedLine: RunGrouping = { carried: new Set(), parted: true } +export const spannedLine: RunGrouping = { carried: new Set(), parted: false } + +// spec/flavour.md, Marks. The portable reducer must ask this too, or it trims edge whitespace by runs the writers do not spell. +export function inlineRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping): InlineRun[] { + const runs: InlineRun[] = [] + let end = 0 + for (const [offset, { node, outer }] of nodes.entries()) { + if (offset < end) continue + const index = firstIndex + offset + // A carried node spells its marks inside the carry. + const mark = carries(node, grouping.carried, index) ? undefined : nodeMarks(node)[outer] + if (mark === undefined) { + runs.push({ index, kind: 'plain', node }) + continue + } + end = runEnd(nodes, offset, mark, firstIndex, grouping) + runs.push({ index, kind: 'marked', mark, nodes: nodes.slice(offset, end).map((runNode) => insideRun(runNode, mark) ?? runNode) }) + } + return runs +} + +// A run opens and closes at a node holding its mark, so a node it would cross past the last such node stays outside. +function runEnd(nodes: readonly RunNode[], first: number, mark: AdfMark, firstIndex: number, { carried, parted }: RunGrouping): number { + let end = first + 1 + for (let offset = end; offset < nodes.length; offset += 1) { + const runNode = nodes[offset] + if (runNode === undefined || carries(runNode.node, carried, firstIndex + offset)) break + if (insideRun(runNode, mark) !== undefined) end = offset + 1 + else if (parted || !runCrosses(runNode.node, mark)) break + } + return end +} + +export function insideRun({ node, outer }: RunNode, mark: AdfMark): RunNode | undefined { + const held = nodeMarks(node)[outer] + return held !== undefined && identicalMark(held, mark) ? { node, outer: outer + 1 } : undefined +} + +// The readers refuse `marks=empty` inside a mark's spelling. +function runCrosses(node: AdfNode, mark: AdfMark): boolean { + const kind = markSpelling(mark.type)?.kind + return (kind === 'emphasis' || kind === 'link') && !takesMark(node, mark.type) && node.marks?.length !== 0 +} + +// Decides the text-node and slot-fault carries up front, `withheldMarkFallbacks` the withheld-mark ones, so a line holding many of them is emitted once. +export function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean { + if (carried.has(index)) return true + if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === '' + const model = inlineNodeModel(node.type) + if (model === undefined) return true + const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute] + return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined +} diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 9d6396a..309b291 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -1,3 +1,4 @@ +import type { AdfMark } from '../../adf/document.ts' import type { Claims, WrittenFlavour } from '../flavour.ts' import type { InlineReading } from '../inline-content.ts' import type { InlineSource, InlineToken } from '../inline-tokens.ts' @@ -19,8 +20,7 @@ export type InlineEscaping = (typeof escapingCodes)[number] export type NodeRange = { first: number; last: number } -// `depth` is the run's nesting level. On a parted line, the only line a fallback reads, that is the index into each node's marks array that the run spells. -export type MarkRun = NodeRange & { depth: number } +export type MarkRun = NodeRange & { mark: AdfMark } export type InlineSegment = | { emphasis: DelimiterRole; escaping: 'none'; highlight?: undefined; nodes: MarkRun; text: string } diff --git a/src/markdown/emit/unspellable-runs.ts b/src/markdown/emit/unspellable-runs.ts index 46af5df..6817539 100644 --- a/src/markdown/emit/unspellable-runs.ts +++ b/src/markdown/emit/unspellable-runs.ts @@ -1,9 +1,9 @@ -import type { AdfNode } from '../../adf/document.ts' +import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { DelimiterRole, MarkRun, PlacedSegment } from './line-escaping.ts' import type { InlineReading } from '../inline-content.ts' import { delimiterFlags, isWordCharacter } from '../commonmark/emphasis-matching.ts' import { highlightFlanking } from '../inline-tokens.ts' -import { nodeMarks } from '../../adf/document.ts' +import { identicalMark, nodeMarks } from '../../adf/document.ts' // How the caller takes a run back: a carry swaps its nodes for one carry directive, splitting every run holding it; a drop removes its mark from the line's `nodes`. export type RunFallback = { kind: 'carry' } | { kind: 'drop'; nodes: readonly AdfNode[] } @@ -88,14 +88,13 @@ function mergingEnds(pairs: readonly EmphasisPair[], fallback: RunFallback): Set // Dropping the run merges no runs and moves no run's end, whatever else drops: at each end, the node outside holds no mark type the end node holds inside the run's mark. function dropsAlone(nodes: readonly AdfNode[], run: MarkRun): boolean { - return endsAlone(nodes[run.first], nodes[run.first - 1], run.depth) && endsAlone(nodes[run.last], nodes[run.last + 1], run.depth) + return endsAlone(nodes[run.first], nodes[run.first - 1], run.mark) && endsAlone(nodes[run.last], nodes[run.last + 1], run.mark) } -function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, depth: number): boolean { - const besideTypes = new Set(nodeMarks(beside ?? {}).map((mark) => mark.type)) - return nodeMarks(edge ?? {}) - .slice(depth + 1) - .every((mark) => !besideTypes.has(mark.type)) +function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, mark: AdfMark): boolean { + const besideTypes = new Set(nodeMarks(beside ?? {}).map((held) => held.type)) + const marks = nodeMarks(edge ?? {}) + return marks.slice(marks.findIndex((held) => identicalMark(held, mark)) + 1).every((held) => !besideTypes.has(held.type)) } // A pair falls with the first only where that leaves the line as the one-run-per-pass reference writes it: it falls cleanly, and cannot read whichever character it is written with. diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 64ed82b..6718d8b 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -2,14 +2,13 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { LinkDefinition, TitleEscapes } from './commonmark/link-syntax.ts' import type { MarkType } from '../adf/mark-attributes.ts' -import { emptyKeys, identicalMark, nodeAttrs, nodeMarks } from '../adf/document.ts' +import { emptyKeys, nodeAttrs, nodeMarks } from '../adf/document.ts' import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts' import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive/syntax.ts' import { holdsEntityReference } from './commonmark/entity-references.ts' import { isAutolink } from './commonmark/grammar.ts' import { isMarkType, linkAttrs, linkMarkFrom, markAttributes, type LinkAttrs } from '../adf/mark-attributes.ts' import { spellEmptyKeys } from './directive/empty-keys.ts' -import { takesMark } from '../adf/inline-nodes.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } @@ -18,9 +17,6 @@ export type MarkSpelling = Spelling & { attributes: AttributeVocabulary } export type CommonMarkLink = { form: 'autolink'; target?: undefined } | { form: 'inline'; target: string } -// An inline node inside the runs of its first `outer` marks. -export type RunNode = { node: AdfNode; outer: number } - const markSpellings: Readonly> = { border: { kind: 'directive' }, code: { kind: 'code' }, @@ -45,18 +41,6 @@ export function isEmphasis(mark: AdfMark): boolean { return markSpelling(mark.type)?.kind === 'emphasis' } -// spec/flavour.md, Marks. -export function insideRun({ node, outer }: RunNode, mark: AdfMark): RunNode | undefined { - const held = nodeMarks(node)[outer] - return held !== undefined && identicalMark(held, mark) ? { node, outer: outer + 1 } : undefined -} - -// spec/flavour.md, Marks. The readers refuse `marks=empty` inside a mark's spelling. -export function runSpans(node: AdfNode, mark: AdfMark): boolean { - const kind = markSpelling(mark.type)?.kind - return (kind === 'emphasis' || kind === 'link') && !takesMark(node, mark.type) && node.marks?.length !== 0 -} - export function linkMark(definition: LinkDefinition): AdfMark { return linkMarkFrom(toLinkAttrs(definition)) } diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 73c19ab..96d7bb7 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -5,6 +5,7 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/doc import { adfToPortableMarkdown, reduceToPortable } from './adf-to-portable-markdown.ts' import { largestNesting } from '../../nesting.ts' import { portableLineFallback } from '../emit/inline-line.ts' +import { spannedLine } from '../emit/inline-runs.ts' import { portableMarkdownToAdf } from './portable-markdown-to-adf.ts' const code: AdfMark = { type: 'code' } @@ -411,13 +412,13 @@ test('drops every em, strike and strong mark, and keeps every other, in a line s test('gives up every highlight run whose own delimiter cannot flank in one fallback, so a line of them re-emits once however many it holds', () => { const highlight: AdfMark = { type: 'backgroundColor' } const line = [text('a '), text('x', highlight), text('a '), text('x', highlight), text('a '), text('x', highlight), text('a')] - const runs = [{ depth: 0, first: 1, last: 1 }, { depth: 0, first: 3, last: 3 }, { depth: 0, first: 5, last: 5 }] - assert.deepEqual(portableLineFallback(line, 'paragraph', []), { ok: true, value: { kind: 'unspellable-run', runs } }) + const runs = [{ first: 1, last: 1, mark: highlight }, { first: 3, last: 3, mark: highlight }, { first: 5, last: 5, mark: highlight }] + assert.deepEqual(portableLineFallback(line, 'paragraph', [], spannedLine), { ok: true, value: { kind: 'unspellable-run', runs } }) }) test('refuses a portable line holding a node only the carry spells, at the line', () => { for (const mark of [{ attrs: { a: 1 }, type: 'em' }, { type: 'odd' }]) { - const refused = portableLineFallback([text('x', mark)], 'paragraph', ['content', 0]) + const refused = portableLineFallback([text('x', mark)], 'paragraph', ['content', 0], spannedLine) assert.deepEqual(refused.ok ? refused : [refused.error.code, refused.error.path], ['unsupported-node-shape', ['content', 0]]) } }) @@ -427,8 +428,15 @@ test('spells an emphasis once across the code spans and hard breaks between two assert.equal(portable(paragraph(text('One', em), { type: 'hardBreak' }, text('Two', em))), '_One\\\nTwo_\n') assert.equal(portable(paragraph(text('a', link('/u')), { type: 'hardBreak' }, text('b', link('/u')))), '[a\\\nb](/u)\n') assert.equal(portable(paragraph(text('Ping ', strong), { attrs: { id: 'x', text: '@A' }, type: 'mention' }, text(' now', strong))), '**Ping** @A **now**\n') - assert.equal(portable(paragraph(text('a', link('/u')), { type: 'hardBreak' }, text(' b', link('/u')))), '[a](/u)\\\n[ b](/u)\n') - assert.equal(portable(paragraph(text('a\n b', link('/u')))), '[a](/u)\\\n[ b](/u)\n') + assert.equal(portable(paragraph(text('a ', link('/u')), { type: 'hardBreak' }, text(' b', link('/u')))), '[a\\\nb](/u)\n') + assert.equal(portable(paragraph(text('a\n b', link('/u')))), '[a\\\nb](/u)\n') + assert.equal(portable(paragraph(text('a ', link('/u')), { type: 'hardBreak' }, text('b'))), '[a ](/u)\\\nb\n') +}) + +test('writes a line whose spanned spelling misreads as the parted line, whitespace outside its runs and no directive', () => { + assert.equal(portable(paragraph(text('a', strong), text('b ', em, strong), text('a', code), text('a', em), text('a'))), '**a**_**b**_ `a`*a*a\n') + const strike: AdfMark = { type: 'strike' } + assert.equal(portable(paragraph(text('a', strong, strike), text('a', code), text(' c', strong, strike), text('b '))), '**~~a~~**`a` ~~c~~b\n') }) test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => { diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts index 63d29ad..3451e17 100644 --- a/src/markdown/portable/inline-reduction.test.ts +++ b/src/markdown/portable/inline-reduction.test.ts @@ -6,9 +6,8 @@ import type { AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import type { PortableLineFallback } from '../emit/inline-line.ts' import { lineContainer, markedLine, propertyRuns } from '../../conformance/property-harness.ts' -import { nodeMarks } from '../../adf/document.ts' -import { portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts' -import { portableLineFallback } from '../emit/inline-line.ts' +import { judgedLine, portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts' +import { spannedLine } from '../emit/inline-runs.ts' import { success, type Result } from '../../result.ts' test('reduces a line alike dropping every run a pass may or the first alone', () => { @@ -56,7 +55,7 @@ test('takes code off every node of an opening link\'s run that an opening-link f }) test('refuses an unspellable-run fallback that takes no mark', () => { - const refused = withoutFallback([{ marks: [{ type: 'em' }], text: 'a', type: 'text' }], { kind: 'unspellable-run', runs: [{ depth: 1, first: 0, last: 0 }] }, []) + const refused = withoutFallback([{ marks: [{ type: 'em' }], text: 'a', type: 'text' }], { kind: 'unspellable-run', runs: [{ first: 0, last: 0, mark: { type: 'strike' } }] }, []) assert.equal(refused.ok ? 'kept' : refused.error.code, 'unsupported-node-shape') }) @@ -65,17 +64,18 @@ function firstRunLine(nodes: readonly AdfNode[], container: LineContainer): Resu const leaves = portableLeaves(nodes, container, [], 0) if (!leaves.ok) return leaves for (let current = leaves.value; ; ) { - const fallback = portableLineFallback(current, container, []) - if (!fallback.ok) return fallback - if (fallback.value === undefined) return success(current) - const fixed = withoutFallback(current, firstRun(current, fallback.value), []) + const judged = judgedLine(current, container, []) + if (!judged.ok) return judged + const { fallback, leaves: line } = judged.value + if (fallback === undefined) return success(line) + const fixed = withoutFallback(line, firstRun(fallback), []) if (!fixed.ok) return fixed - current = trimmedEdges(fixed.value) + current = trimmedEdges(fixed.value, spannedLine) } } -function firstRun(leaves: readonly AdfNode[], fallback: PortableLineFallback): PortableLineFallback { +function firstRun(fallback: PortableLineFallback): PortableLineFallback { if (fallback.kind === 'opening-link') return fallback const [first, ...others] = fallback.runs - return { kind: 'unspellable-run', runs: [first, ...others.filter((run) => nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === 'backgroundColor')] } + return { kind: 'unspellable-run', runs: [first, ...others.filter((run) => run.mark.type === 'backgroundColor')] } } diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 441152e..201b31a 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -1,17 +1,25 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import type { MarkRun } from '../emit/line-escaping.ts' +import type { RunGrouping, RunNode } from '../emit/inline-runs.ts' import { codeTextMarkTypes } from '../../adf/inline-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { imageAside } from '../image-aside.ts' -import { insideRun, isEmphasis } from '../mark-spellings.ts' +import { inlineRuns, insideRun, partedLine, spannedLine } from '../emit/inline-runs.ts' +import { isEmphasis } from '../mark-spellings.ts' import { joinsWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts' -import { portableLineFallback, readBackPasses, runsSharedWithNext, type PortableLineFallback } from '../emit/inline-line.ts' +import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination } from '../commonmark/link-syntax.ts' +// What stands beside a leaf's edge: whether a line edge does, and how many runs hold the leaf and its neighbour both. +type Beside = { lineEdge: boolean; sharedRuns: number } + +// `leaves` is the line `fallback` was judged on. +type JudgedLine = { fallback: PortableLineFallback | undefined; leaves: AdfNode[] } + const highlight = 'backgroundColor' const highlightMark: AdfMark = { type: highlight } const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong'] @@ -26,7 +34,7 @@ export function reduceInline(nodes: readonly AdfNode[], container: LineContainer export function portableLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { const leaves = inlineLeaves(nodes, container, path, depth) if (!leaves.ok) return leaves - return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value)))) + return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value, spannedLine)), spannedLine)) } export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { @@ -161,15 +169,15 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode { return marks.length === 0 ? unmarked : { ...unmarked, marks: [...marks] } } -export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { +export function trimmedEdges(leaves: readonly AdfNode[], grouping: RunGrouping): AdfNode[] { // Terminates: a pass that changes a leaf drops whitespace from the leaf's edge or takes a mark off that whitespace, and merging adds no whitespace or mark. for (let current = leaves; ; ) { const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal)) - const shared = runsSharedWithNext(merged) + const shared = runsSharedWithNext(merged, grouping) let changed = false const trimmed: AdfNode[] = [] for (const [index, leaf] of merged.entries()) { - const edges = leafEdges(leaf, runsBeside(merged[index - 1], shared[index - 1]), runsBeside(merged[index + 1], shared[index])) + const edges = leafEdges(leaf, beside(merged[index - 1], shared[index - 1]), beside(merged[index + 1], shared[index])) if (edges === undefined) { trimmed.push(leaf) continue @@ -190,20 +198,35 @@ function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] { return leaves.slice(first, last + 1) } -// How many runs a leaf shares with its neighbour, `undefined` at a line edge. -function runsBeside(neighbour: AdfNode | undefined, shared: number | undefined): number | undefined { - return neighbour === undefined || neighbour.type === 'hardBreak' ? undefined : shared +// For each leaf but the last, how many runs of the line hold both it and the next leaf. +function runsSharedWithNext(leaves: readonly AdfNode[], grouping: RunGrouping): number[] { + const shared = leaves.map(() => 0) + countSharedRuns(leaves.map((node) => ({ node, outer: 0 })), 0, grouping, shared) + return shared +} + +// Recurses once per mark, and a portable leaf holds one mark of each kept type. +function countSharedRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping, shared: number[]): void { + for (const run of inlineRuns(nodes, firstIndex, grouping)) { + if (run.kind === 'plain') continue + for (let index = run.index; index < run.index + run.nodes.length - 1; index += 1) shared[index] = (shared[index] ?? 0) + 1 + countSharedRuns(run.nodes, run.index, grouping, shared) + } +} + +function beside(neighbour: AdfNode | undefined, sharedRuns = 0): Beside { + return { lineEdge: neighbour === undefined || neighbour.type === 'hardBreak', sharedRuns } } // spec/flavour.md, Inline nodes: edge whitespace leaves every stripping mark opening or closing beside it, and goes at a line edge. -function leafEdges(leaf: AdfNode, runsBefore: number | undefined, runsAfter: number | undefined): AdfNode[] | undefined { +function leafEdges(leaf: AdfNode, before: Beside, after: Beside): AdfNode[] | undefined { const marks = nodeMarks(leaf) const text = leaf.text if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined const lead = text.slice(0, text.search(/[^ \t]|$/)) const trail = text.slice(text.search(/[ \t]*$/)) - const leadDepth = edgeDepth(marks, runsBefore, lead) - const trailDepth = edgeDepth(marks, runsAfter, trail) + const leadDepth = edgeDepth(marks, before, lead) + const trailDepth = edgeDepth(marks, after, trail) if (leadDepth === marks.length && trailDepth === marks.length) return undefined if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))] const edges: AdfNode[] = [] @@ -214,26 +237,40 @@ function leafEdges(leaf: AdfNode, runsBefore: number | undefined, runsAfter: num return edges } -// How many of the leaf's marks the edge whitespace keeps, `undefined` where it drops at a line edge. -function edgeDepth(marks: readonly AdfMark[], sharedRuns: number | undefined, whitespace: string): number | undefined { +// How many of the leaf's marks the edge whitespace keeps, `undefined` where it drops at a line edge. Text not marked `code` holds every run over it, so the runs it shares are its outermost marks. +function edgeDepth(marks: readonly AdfMark[], { lineEdge, sharedRuns }: Beside, whitespace: string): number | undefined { if (whitespace === '') return marks.length - const stripping = marks.findIndex((mark, index) => index >= (sharedRuns ?? 0) && edgeStrippingMarks.includes(mark.type)) + // A run crossing a hard break holds this whitespace at the edge of a line, where CommonMark strips it. + if (lineEdge && sharedRuns > 0) return undefined + const stripping = marks.findIndex((mark, index) => index >= sharedRuns && edgeStrippingMarks.includes(mark.type)) const kept = stripping === -1 ? marks.length : stripping - return kept === 0 && sharedRuns === undefined ? undefined : kept + return kept === 0 && lineEdge ? undefined : kept } function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { // Terminates: withoutFallback refuses a pass that takes no mark, and trimmedEdges adds none to any character. for (let current = leaves, pass = 1; ; pass += 1) { - const fallback = portableLineFallback(current, container, path) - if (!fallback.ok) return fallback - if (fallback.value === undefined) return success(current) - const fixed = withoutFallback(current, fallback.value, path) + const judged = judgedLine(current, container, path) + if (!judged.ok) return judged + const { fallback, leaves: line } = judged.value + if (fallback === undefined) return success(line) + const fixed = withoutFallback(line, fallback, path) if (!fixed.ok) return fixed - current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value) + current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value, spannedLine) } } +// spec/flavour.md, Marks: the lines in the order the writer tries them, each trimmed by its own runs. +export function judgedLine(spanned: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { + const spannedFallback = portableLineFallback(spanned, container, path, spannedLine) + if (!spannedFallback.ok) return spannedFallback + if (spannedFallback.value === undefined) return success({ fallback: undefined, leaves: [...spanned] }) + const parted = trimmedEdges(spanned, partedLine) + const partedFallback = portableLineFallback(parted, container, path, partedLine) + if (!partedFallback.ok) return partedFallback + return success({ fallback: partedFallback.value, leaves: parted }) +} + export function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback, path: ConvertErrorPath): Result { const fixed = fallback.kind === 'unspellable-run' ? withoutMarks(leaves, fallback.runs) : withoutOpeningLinkCode(leaves) return fixed === undefined ? failure('unsupported-node-shape', 'a portable line keeps a spelling that dropping a mark does not change', path) : success(fixed) @@ -253,19 +290,19 @@ function withoutEmphasis(leaves: readonly AdfNode[]): AdfNode[] { return leaves.map((leaf) => withMarks(leaf, nodeMarks(leaf).filter((mark) => !isEmphasis(mark)))) } -// The runs one pass takes back may nest, so a leaf drops its mark at each depth a run over it names. +// The runs one pass takes back may nest, so a leaf drops the mark of each run over it. function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] | undefined { - const depths = new Map() + const droppedAt = new Map() for (const run of runs) { for (let index = run.first; index <= run.last; index += 1) { - const held = depths.get(index) - if (held === undefined) depths.set(index, [run.depth]) - else held.push(run.depth) + const dropped = droppedAt.get(index) + if (dropped === undefined) droppedAt.set(index, [run.mark]) + else dropped.push(run.mark) } } const kept = leaves.map((leaf, index) => { - const dropped = depths.get(index) - return dropped === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => !dropped.includes(held))) + const dropped = droppedAt.get(index) + return dropped === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => !dropped.some((mark) => identicalMark(held, mark)))) }) return markCount(kept) < markCount(leaves) ? kept : undefined }