diff --git a/CHANGELOG.md b/CHANGELOG.md index 506061b..c5659ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,8 +58,9 @@ as in `[a](b` repeated: 16000 of them took 25 seconds, now 80 ms. - `adfToLosslessMarkdown` and `adfToPortableMarkdown` write faster a line of many marked texts or inline nodes that need a carry or lose their mark, as in text `un` beside strong `-r`, repeated: - 1000 of them took 10 seconds, now 150 ms. A line mixing em, strike and strong can still take time - quadratic in its length, as in text `un` beside `-r` marked em and strong, repeated. + 1000 of them took 10 seconds, now 150 ms. So does a line mixing em, strike and strong: 1000 + `un` beside `-r` marked em and strong took 9 seconds, now 140 ms, and 1000 `(` marked em, strike + and strong beside a mention took 18 seconds in `adfToPortableMarkdown`, now 110 ms. - `adfToLosslessMarkdown` and `adfToPortableMarkdown` take time linear in the markdown when lists and quotes nest deeply, and so does `losslessMarkdownToAdf`: a list nested 480 levels around 64 KB of paragraphs took 2 seconds to write, now 100 ms. A `!adf:blockquote` holding 2 MB of lines diff --git a/src/markdown/emit/inline-line.test.ts b/src/markdown/emit/inline-line.test.ts index 1997c01..1272a77 100644 --- a/src/markdown/emit/inline-line.test.ts +++ b/src/markdown/emit/inline-line.test.ts @@ -10,13 +10,15 @@ test('spells a line alike taking every fallback a pass finds or the first alone' fc.property(markedLine, lineContainer, (nodes, container) => { assert.deepEqual(emitInlineLine(nodes, container, [], 'lossless'), emitInlineLine(nodes, container, [], 'lossless', 'first')) }), - // Lines where carrying one run lets a neighbour read as written, which the seed's runs rarely reach. + // Lines the seed's runs rarely reach: carrying one run lets a neighbour read as written, or one pass carries a repeated run's every copy. { ...propertyRuns(3000), examples: [ [[{ marks: [{ type: 'em' }], text: '-', type: 'text' }, { text: '0', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strong' }], text: 'un', type: 'text' }, { text: '0', type: 'text' }], 'paragraph'], [[{ marks: [{ type: 'em' }, { type: 'strike' }], text: 'a', type: 'text' }, { text: 'a', type: 'text' }, { marks: [{ type: 'strong' }, { type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'strong' }], text: 'a', type: 'text' }], 'paragraph'], [[{ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strike' }, { type: 'strong' }], text: 'a', type: 'text' }, { text: 'a', type: 'text' }], 'paragraph'], + [[1, 2, 3].flatMap(() => [{ text: 'un', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strong' }], text: '-r', type: 'text' }]), 'paragraph'], + [[{ text: 'un', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strong' }], text: '-r', type: 'text' }, { marks: [{ type: 'strike' }], text: 'un', type: 'text' }, { marks: [{ type: 'strike' }, { type: 'em' }, { type: 'strong' }], text: '-r', type: 'text' }, { text: 'z', type: 'text' }], 'paragraph'], ], }, ) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 6386acb..b26938e 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -3,7 +3,7 @@ import type { InlineNodeModel } from '../../adf/inline-nodes.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinition, TitleEscapes } from '../commonmark/link-syntax.ts' import type { WrittenFlavour } from '../flavour.ts' -import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts' +import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange, type RunFallback } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' @@ -43,12 +43,10 @@ type InlineContext = { type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } -type LineAttempt = { fallback: Fallback; line?: undefined } | { fallback?: undefined; line: string } +type LineAttempt = { fallback: Fallback; line?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } -type LinePass = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } - export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] } export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour, perPass: FallbacksPerPass = 'every'): Result { @@ -63,11 +61,11 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve return success(emitted.value.openingLinkAsDirective) } -export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { +export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, alone: (run: MarkRun) => boolean): Result { const emission = lineSegments(nodes, container, path, withheldMarkFallbacks(nodes, 'portable')) if (!emission.ok) return emission if (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') + const verdict = lineVerdict(emission.value.segments, container, 'portable', { alone, kind: 'drop' }) return success(verdict.kind === 'line' ? undefined : verdict) } @@ -95,23 +93,16 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con const fallbacks = withheldMarkFallbacks(nodes, flavour) // Terminates because takeFallback refuses a pass that took no new fallback. for (;;) { - const pass = linePass(nodes, container, path, fallbacks) - if (!pass.ok) return pass - const { fallback, line, segments } = pass.value - if (fallback === undefined) return success({ line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments }) - const taken = takeFallback(fallbacks, takenThisPass(fallback, perPass), path) + const emission = lineSegments(nodes, container, path, fallbacks) + if (!emission.ok) return emission + const { carries, segments } = emission.value + const attempt = segments === undefined ? { fallback: carries } : attemptLine(segments, container, flavour) + if (attempt.fallback === undefined) return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: attempt.segments }) + const taken = takeFallback(fallbacks, takenThisPass(attempt.fallback, perPass), path) if (!taken.ok) return taken } } -function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - const emission = lineSegments(nodes, container, path, fallbacks) - if (!emission.ok) return emission - if (emission.value.carries !== undefined) return success({ fallback: emission.value.carries }) - const attempt = attemptLine(emission.value.segments, container, fallbacks.flavour) - return success(attempt.line === undefined ? { fallback: attempt.fallback } : { line: attempt.line, segments: emission.value.segments }) -} - function takenThisPass(fallback: Fallback, perPass: FallbacksPerPass): Fallback { return fallback === 'opening-link' || perPass === 'every' ? fallback : [fallback[0]] } @@ -146,16 +137,16 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: return success({ segments: spellEdgeWhitespace(emission.value.segments) }) } -function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt { - const verdict = lineVerdict(segments, container, flavour) +function attemptLine(segments: InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt { + const verdict = lineVerdict(segments, container, flavour, { kind: 'carry' }) if (verdict.kind === 'opening-link') return { fallback: 'opening-link' } if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs } - return { line: verdict.text } + return { line: verdict.text, segments } } // The fallbacks in the order a line takes them, or the line where it takes none. -function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): PortableLineFallback | { kind: 'line'; text: string } { - const assembled = assembleInlineLine(segments, container, flavour) +function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour, fallback: RunFallback): PortableLineFallback | { kind: 'line'; text: string } { + const assembled = assembleInlineLine(segments, container, flavour, fallback) if (assembled.openingLinkAsDirective) return { kind: 'opening-link' } const [run, ...others] = assembled.unspellableRuns return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] } diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 6cc208d..d956e3c 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -28,16 +28,36 @@ export type InlineSegment = export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] } +// How the caller takes a run back: a carry swaps its nodes for one blob, splitting the runs around it; a drop removes its mark, moving no other delimiter where `alone` holds. +export type RunFallback = { kind: 'carry' } | { alone: (run: MarkRun) => boolean; kind: 'drop' } + type ScanLine = { position: LinePosition; start: number; text: string } -// An emphasis run's delimiters, written alike, and where each landed in the output. +// An emphasis run's delimiters, written alike, by index into the placed segments. type EmphasisPair = { closer: number; delimiter: string; nodes: MarkRun; opener: number } type Escapings = (index: number) => InlineEscaping | undefined +// Which emphasis pairs fall with the first misread one, each marked in `fallen` once settled. +type PairSweep = { + readonly fallback: RunFallback + readonly fallen: Uint8Array + readonly first: number + // The leaf boundaries where dropping a pair that may fall first can merge the runs meeting there. + readonly merging: ReadonlySet + readonly output: string + // The pair each placed segment delimits, -1 where it delimits none. + readonly pairAt: Int32Array + readonly pairs: readonly EmphasisPair[] + readonly placed: readonly PlacedSegment[] +} + // Where a segment's first character landed in the output. type PlacedSegment = { segment: InlineSegment; start: number } +// How a pair stands while another falls: `either` where it may fall first. +type Standing = 'either' | 'gone' | 'there' + // The escaped line once reading it back asks for no further escape. type ReadBack = { output: string; placed: PlacedSegment[]; reading: InlineReading } @@ -59,8 +79,8 @@ const escapingCodes: readonly ['backslash', 'bracketed', 'bracketed-link-target' const runCharacters = '*_`~' -export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): AssembledLine { - return escape(resolveEmphasis(segments), container, flavourClaims[flavour]) +export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour, fallback: RunFallback): AssembledLine { + return escape(resolveEmphasis(segments), container, flavourClaims[flavour], fallback) } function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { @@ -92,9 +112,9 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { return resolved } -function escape(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): AssembledLine { +function escape(segments: readonly InlineSegment[], container: LineContainer, claims: Claims, fallback: RunFallback): AssembledLine { const { output, placed, reading } = readBack(segments, container, claims) - return { ...guardLinkDefinition(output, placed, container), unspellableRuns: unspellableRuns(placed, reading, output) } + return { ...guardLinkDefinition(output, placed, container), unspellableRuns: unspellableRuns(placed, reading, output, fallback) } } function readBack(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): ReadBack { @@ -270,39 +290,157 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text: } } -// The runs the line does not read back as written: the first emphasis run to close and each later one that gives up with it, else every misflanked highlight run. One misread emphasis pair can re-pair the delimiters after it. -function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading, output: string): MarkRun[] { - const misread: MarkRun[] = [] - const misflanked: MarkRun[] = [] - const openers: { nodes: MarkRun; start: number }[] = [] - for (const { segment, start } of placed) { - if (segment.emphasis === 'open') openers.push({ nodes: segment.nodes, start }) - // No `==` in text can open or close, and highlights never nest, so a highlight run reads back where both its delimiters flank. - if (segment.highlight !== undefined && !highlightFlanks(output, start, segment.highlight) && misflanked.at(-1) !== segment.nodes) misflanked.push(segment.nodes) +// The runs the line does not read back as written: the first emphasis run to close and every run that falls with it, else every misflanked highlight run. One misread emphasis pair can re-pair the delimiters after it. +function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading, output: string, fallback: RunFallback): MarkRun[] { + const { pairAt, pairs } = emphasisPairs(placed) + const first = pairs.findIndex((pair) => !readAsWritten(reading, placed, pair)) + const firstPair = pairs[first] + if (firstPair === undefined) return misflankedHighlights(placed, output) + const merging = new Set() + if (fallback.kind === 'drop') for (const { nodes } of pairs) if (!fallback.alone(nodes)) merging.add(nodes.first).add(nodes.last + 1) + const sweep: PairSweep = { fallback, fallen: new Uint8Array(pairs.length), first, merging, output, pairAt, pairs, placed } + sweep.fallen[first] = 1 + // Each verdict reads the verdicts settled before it, so the sweep settles outward from the first pair. + for (let judged = first + 1; judged < pairs.length; judged += 1) if (fallsWithFirst(sweep, judged)) sweep.fallen[judged] = 1 + for (let judged = first - 1; judged >= 0; judged -= 1) if (fallsWithFirst(sweep, judged)) sweep.fallen[judged] = 1 + const runs = [firstPair.nodes] + for (const [index, pair] of pairs.entries()) if (index !== first && sweep.fallen[index] === 1) runs.push(pair.nodes) + return runs +} + +// The pairs in closing order. +function emphasisPairs(placed: readonly PlacedSegment[]): { pairAt: Int32Array; pairs: EmphasisPair[] } { + const pairAt = new Int32Array(placed.length).fill(-1) + const pairs: EmphasisPair[] = [] + const openers: { at: number; nodes: MarkRun }[] = [] + for (const [at, { segment }] of placed.entries()) { + if (segment.emphasis === 'open') openers.push({ at, nodes: segment.nodes }) const opener = segment.emphasis === 'close' ? openers.pop() : undefined if (opener === undefined) continue - const pair: EmphasisPair = { closer: start, delimiter: segment.text, nodes: opener.nodes, opener: opener.start } - if (givesUp(pair, misread.at(-1), reading, output)) misread.push(pair.nodes) + pairAt[opener.at] = pairs.length + pairAt[at] = pairs.length + pairs.push({ closer: at, delimiter: segment.text, nodes: opener.nodes, opener: opener.at }) } - return misread.length > 0 ? misread : misflanked + return { pairAt, pairs } } -// The first run gives up where the reading pairs its delimiters otherwise. A later run gives up where it holds none of the earlier runs, so dropping them leaves its range alone, and one of its delimiters cannot flank. -function givesUp(pair: EmphasisPair, latest: MarkRun | undefined, reading: InlineReading, output: string): boolean { - if (latest === undefined) return !readAsWritten(reading, pair.opener, pair.closer, pair.delimiter.length) - // Runs close in order, so a run holding any earlier one holds the latest. - if (pair.nodes.first <= latest.first && latest.last <= pair.nodes.last) return false - return !mayFlank(output, pair.opener, pair.delimiter, 'open') || !mayFlank(output, pair.closer, pair.delimiter, 'close') +// No `==` in text can open or close, and highlights never nest, so a highlight run reads back where both its delimiters flank. +function misflankedHighlights(placed: readonly PlacedSegment[], output: string): MarkRun[] { + const misflanked: MarkRun[] = [] + for (const { segment, start } of placed) { + if (segment.highlight !== undefined && !highlightFlanks(output, start, segment.highlight) && misflanked.at(-1) !== segment.nodes) misflanked.push(segment.nodes) + } + return misflanked } -// Whether the delimiter flanks once earlier runs fall back: a delimiter character beside it may fall back, while text or the run's own hard break stays. -function mayFlank(output: string, start: number, delimiter: string, role: DelimiterRole): boolean { - const before = output.charAt(start - 1) - const after = output.charAt(start + delimiter.length) - const character = delimiter.charAt(0) - if (before === character || after === character) return true - const flags = delimiterFlags(character, before, after) - return role === 'open' ? flags.canOpen : flags.canClose +// A pair falls with the first where one run per pass would take it back too: one of its delimiters cannot flank however the unsettled pairs fall, and taking it back moves no delimiter another verdict reads. +function fallsWithFirst(sweep: PairSweep, judged: number): boolean { + if (!fallsCleanly(sweep, judged)) return false + const spellings = pairSpellings(sweep, judged, judged) + return spellings !== undefined && spellings.every((character) => !mayFlank(sweep, judged, character, 'opener') || !mayFlank(sweep, judged, character, 'closer')) +} + +// A carry splits every pair holding it, so a carried pair holds no pair that may fall first; a dropped pair keeps its leaves where no run merges at its ends. +function fallsCleanly({ fallback, merging, pairs }: PairSweep, judged: number): boolean { + const pair = pairs[judged] + if (pair === undefined) return false + return fallback.kind === 'drop' ? !mergesAt(merging, pair) : !holdsEarlier(pairs, judged, pair) +} + +function mergesAt(merging: ReadonlySet, { nodes }: EmphasisPair): boolean { + return merging.has(nodes.first) || merging.has(nodes.last + 1) +} + +// CommonMark judges a delimiter by the characters either side of its whole run of `character`. +function mayFlank(sweep: PairSweep, judged: number, character: string, end: 'closer' | 'opener'): boolean { + const at = sweep.pairs[judged]?.[end] ?? -1 + const before = runEdge(sweep, judged, at, -1, character) + const after = runEdge(sweep, judged, at, 1, character) + if (before === undefined || after === undefined) return true + return before.some((left) => + after.some((right) => { + const flags = delimiterFlags(character, left, right) + return end === 'opener' ? flags.canOpen : flags.canClose + }), + ) +} + +// The characters that may stand past the run of `character` holding the delimiter at `from`, walking by `step`; `undefined` where a pair beside may move. +function runEdge(sweep: PairSweep, judged: number, from: number, step: -1 | 1, character: string): string[] | undefined { + const found: string[] = [] + // A backslash escaping the next text character stands past the last delimiter walked, so the walk tracks where that delimiter ends. + let edge = delimiterEdge(sweep.placed, from, step) + for (let at = from + step; ; at += step) { + const segment = sweep.placed[at]?.segment + const other = sweep.pairAt[at] ?? -1 + if (segment === undefined || (other === -1 && segment.text !== '')) break + if (other === -1) continue + const standing = pairStanding(sweep, judged, other) + const spellings = standing === 'gone' ? [] : pairSpellings(sweep, judged, other) + if (standing === undefined || spellings === undefined) return undefined + for (const spelling of spellings) if (spelling !== character) found.push(spelling) + if (standing === 'there' && !spellings.includes(character)) return found + edge = delimiterEdge(sweep.placed, at, step) + } + found.push(sweep.output.charAt(step === 1 ? edge : edge - 1)) + return found +} + +function delimiterEdge(placed: readonly PlacedSegment[], at: number, step: -1 | 1): number { + const delimiter = placed[at] + if (delimiter === undefined) return 0 + return step === 1 ? delimiter.start + delimiter.segment.text.length : delimiter.start +} + +// How the other pair stands when the judged one falls. Past the first, every pair closing after the judged one still stands, since that one cannot read as written; `undefined` where a carry or a merge may move the other's delimiters. +function pairStanding({ fallback, fallen, first, merging, pairs }: PairSweep, judged: number, other: number): Standing | undefined { + const pair = pairs[other] + if (other === judged) return 'there' + if (pair === undefined || mergesAt(merging, pair)) return undefined + if (other > judged && judged > first) return 'there' + if (fallback.kind === 'carry') return undefined + return fallen[other] === 1 ? 'gone' : 'either' +} + +// The characters the other pair may be written with when the judged one falls: emphasis takes `_`, turning `*` beside a word character. +function pairSpellings(sweep: PairSweep, judged: number, other: number): string[] | undefined { + const pair = sweep.pairs[other] + if (pair === undefined) return undefined + if (pair.delimiter.length > 1) return [pair.delimiter.charAt(0)] + // The carry's punctuation stands where the split pair meets it. + if (sweep.fallback.kind === 'carry' && other !== judged && holdsEarlier(sweep.pairs, judged, pair)) return ['*', '_'] + const before = wordBeside(sweep, judged, pair.opener, -1) + const after = wordBeside(sweep, judged, pair.closer, 1) + if (before === undefined || after === undefined) return undefined + const spellings: string[] = [] + if (before.includes(true) || after.includes(true)) spellings.push('*') + if (before.includes(false) && after.includes(false)) spellings.push('_') + return spellings +} + +// Whether a word character may stand beside the delimiter at `from` as resolveEmphasis reads the line, for each way the unsettled pairs fall. +function wordBeside(sweep: PairSweep, judged: number, from: number, step: -1 | 1): boolean[] | undefined { + const found: boolean[] = [] + for (let at = from + step; ; at += step) { + const segment = sweep.placed[at]?.segment + const other = sweep.pairAt[at] ?? -1 + if (segment !== undefined && segment.text === '') continue + if (segment === undefined || other === -1) { + found.push(segment !== undefined && isWordCharacter(segment.text.charAt(step === 1 ? 0 : segment.text.length - 1))) + return found + } + const standing = pairStanding(sweep, judged, other) + if (standing === undefined) return undefined + if (standing === 'gone') continue + found.push(false) + if (standing === 'there') return found + } +} + +// Pairs close in order, so a pair holding any pair that closes before the judged one holds the one just before it. +function holdsEarlier(pairs: readonly EmphasisPair[], judged: number, pair: EmphasisPair): boolean { + const earlier = pairs[judged - 1] + return earlier !== undefined && pair.nodes.first <= earlier.nodes.first && earlier.nodes.last <= pair.nodes.last } function highlightFlanks(output: string, start: number, role: DelimiterRole): boolean { @@ -310,9 +448,9 @@ function highlightFlanks(output: string, start: number, role: DelimiterRole): bo return role === 'open' ? flanking.opens : flanking.closes } -function readAsWritten(reading: InlineReading, opener: number, closer: number, width: number): boolean { - const read = reading.emphasis.get(opener) - return read?.closer === closer && read.width === width +function readAsWritten(reading: InlineReading, placed: readonly PlacedSegment[], pair: EmphasisPair): boolean { + const read = reading.emphasis.get(placed[pair.opener]?.start ?? -1) + return read !== undefined && read.closer === placed[pair.closer]?.start && read.width === pair.delimiter.length } function isEscapable(escaping: InlineEscaping | undefined): boolean { diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index f370049..ec48a68 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -405,11 +405,11 @@ test('gives up every highlight run whose own delimiter cannot flank in one fallb 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 } }) + assert.deepEqual(portableLineFallback(line, 'paragraph', [], () => false), { ok: true, value: { kind: 'unspellable-run', runs } }) }) test('refuses a portable line holding a node only the carry spells, at the line', () => { - const refused = portableLineFallback([text('x', { type: 'odd' })], 'paragraph', ['content', 0]) + const refused = portableLineFallback([text('x', { type: 'odd' })], 'paragraph', ['content', 0], () => false) assert.deepEqual(refused.ok ? refused : [refused.error.code, refused.error.path], ['unsupported-node-shape', ['content', 0]]) }) diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts index 679157e..b17dbcb 100644 --- a/src/markdown/portable/inline-reduction.test.ts +++ b/src/markdown/portable/inline-reduction.test.ts @@ -10,12 +10,17 @@ test('reduces a line alike dropping every run a pass may or the first alone', () fc.property(markedLine, lineContainer, (nodes, container) => { assert.deepEqual(reduceInline(nodes, container, [], 0), reduceInline(nodes, container, [], 0, 'first')) }), - // Lines holding a later run that must not drop with the first, which the seed's runs rarely reach. + // Lines the seed's runs rarely reach: a later run must not drop with the first, or one pass drops a repeated run's every copy. { ...propertyRuns(3000), examples: [ [[{ attrs: { id: 'a' }, type: 'mention' }, { marks: [{ type: 'strike' }, { type: 'strong' }], text: 'un*', type: 'text' }, { marks: [{ type: 'strong' }], text: '[', type: 'text' }, { text: 'a ', type: 'text' }, { text: ' ', type: 'text' }], 'heading'], [[{ marks: [{ type: 'strong' }], text: '-', type: 'text' }, { text: '0', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strong' }], text: 'un ', type: 'text' }, { text: ' ', type: 'text' }], 'heading'], + [[{ marks: [{ type: 'em' }, { type: 'strike' }], text: '`', type: 'text' }, { text: 'a ', type: 'text' }, { marks: [{ type: 'strike' }], text: 'un', type: 'text' }, { marks: [{ type: 'strike' }, { type: 'em' }, { type: 'strong' }], text: '-r', type: 'text' }, { marks: [{ type: 'underline' }], text: 'z', type: 'text' }], 'heading'], + [[1, 2, 3].flatMap(() => [{ text: 'un', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strong' }], text: '-r', type: 'text' }]), 'paragraph'], + [[1, 2, 3].flatMap(() => [{ marks: [{ type: 'em' }, { type: 'strike' }, { type: 'strong' }], text: '(', type: 'text' }, { attrs: { id: 'a' }, type: 'mention' }]), 'paragraph'], + [[{ text: 'z', type: 'text' }, ...[1, 2, 3].flatMap(() => [{ marks: [{ type: 'strike' }], text: '(b', type: 'text' }, { marks: [{ type: 'em' }], text: '(b', type: 'text' }])], 'paragraph'], + [[...[1, 2, 3].flatMap(() => [{ marks: [{ type: 'strike' }], text: 'b(', type: 'text' }, { marks: [{ type: 'em' }], text: 'b(', type: 'text' }]), { text: 'z', type: 'text' }], 'paragraph'], ], }, ) diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 13865e3..4c3bda4 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -220,7 +220,7 @@ function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath, perPass: FallbacksPerPass): Result { for (let current = leaves; ; ) { - const fallback = portableLineFallback(current, container, path) + const fallback = portableLineFallback(current, container, path, (run) => dropsAlone(current, run)) if (!fallback.ok) return fallback if (fallback.value === undefined) return success(current) const fixed = withoutFallback(current, fallback.value, perPass) @@ -240,23 +240,35 @@ function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallb return spans ? leaves.map((leaf, index) => (index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined } -// The runs cover disjoint leaves, so one pass drops them all. +// The runs may nest, so a leaf drops the mark at every depth a run holds it. function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] { - const depths = new Map() - for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth) + const depths = 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) + } + } return leaves.map((leaf, index) => { - const depth = depths.get(index) - return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth)) + const dropped = depths.get(index) + return dropped === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => !dropped.includes(held))) }) } -// Every misflanked highlight drops with the first run; with `every`, so does a later emphasis run whose drop merges no run and moves no edge. -function droppedRuns(leaves: readonly AdfNode[], [first, ...others]: readonly [MarkRun, ...MarkRun[]], perPass: FallbacksPerPass): MarkRun[] { - return [first, ...others.filter((run) => nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === highlight || (perPass === 'every' && dropsAlone(leaves, run)))] +// Every misflanked highlight drops with the first run; with `every`, so does every emphasis run that falls with it. +function droppedRuns(leaves: readonly AdfNode[], runs: readonly [MarkRun, ...MarkRun[]], perPass: FallbacksPerPass): readonly MarkRun[] { + return perPass === 'every' ? runs : runs.filter((run, index) => index === 0 || nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === highlight) } -// Its mark is its leaves' innermost, and no leaf beside it holds a mark that deep. +// Dropping the run merges no runs and moves no edge, whatever else drops: at each end, the leaf beside holds no mark type the edge leaf holds inside the run's mark. function dropsAlone(leaves: readonly AdfNode[], run: MarkRun): boolean { - const beside = [leaves[run.first - 1], leaves[run.last + 1]].every((leaf) => leaf === undefined || nodeMarks(leaf).length <= run.depth) - return beside && leaves.slice(run.first, run.last + 1).every((leaf) => nodeMarks(leaf).length === run.depth + 1) + return endsAlone(leaves[run.first], leaves[run.first - 1], run.depth) && endsAlone(leaves[run.last], leaves[run.last + 1], run.depth) +} + +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)) } diff --git a/todo.md b/todo.md index a43d28e..81cd41a 100644 --- a/todo.md +++ b/todo.md @@ -12,15 +12,14 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 158 | 0.3.0 | | defect | **Emit a line mixing em, strike and strong in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 1 | 2 | 5 | 5 | 9 by 3 | 7.8 | -| 160 | 0.3.0 | 158 | | **Make the block-text tree and the batched mark fallbacks read as easily as the code before items 33 and 109, with no switch only tests use in production signatures and one rule for which runs lose their mark.** | 3 | 1 | 2 | 4 | 5 | code health by 5 | 13.1 | +| 160 | 0.3.0 | | | **Make the block-text tree and the batched mark fallbacks read as easily as the code before items 33 and 109, with no switch only tests use in production signatures.** | 3 | 1 | 2 | 4 | 5 | code health by 5 | 13.1 | | 150 | 0.3.0 | | decision | **Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, ` `, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.** | 2 | 2 | 2 | 4 | 4 | 6 by 5 | 5.0 | | 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 | | 91 | 0.3.0 | | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 | | 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 | | 132 | 0.3.0 | | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 | -| 73 | 0.3.0 | 91, 115, 132, 133, 150, 158, 160 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | -| 72 | 0.3.0 | 91, 115, 132, 133, 150, 158, 160 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | +| 73 | 0.3.0 | 91, 115, 132, 133, 150, 160 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | +| 72 | 0.3.0 | 91, 115, 132, 133, 150, 160 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | | 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 | | 156 | 0.4.0 | | | **Stop a leg's container when its script is interrupted, so Ctrl-C ends `ci.sh` or `mutation-tests/run.sh` at once.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 20.9 | | 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | @@ -70,31 +69,14 @@ ## Details -### 158. Emit a line mixing em, strike and strong in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`. - -Item 33 batched a line's fallbacks, but two guards keep nested emphasis at one run per pass. A -paragraph repeating text `un` beside `-r` marked em and strong takes 13 seconds at 1000 repeats in -either writer. `(` marked em, strike and strong beside a mention takes 27 seconds in the portable -one. `mayFlank`'s same-character guard (`line-escaping.ts`) is needed: without it -`[{em,'-'},{'0'},{em+strong,'un'},{'0'}]` writes other markdown. `dropsAlone`'s innermost-mark -requirement (`inline-reduction.ts`) has no known counterexample, but relaxing it alone leaves -lossless, the three-mark line and the chain below quadratic. A chain of `z` then strike `(b` beside -em `(b`, repeated, needs one pass per run, since each run's drop decides whether the next run's -delimiter flanks: linear there needs a reading that settles runs left to right in one pass. The -every-against-first properties in `inline-line.test.ts` and `inline-reduction.test.ts` pin every -other guard. The seats find `emitLine` and `unspellableRuns` hard: make both easier to read. -Found by the stability review of item 33, 2026-10-29. - -### 160. Make the block-text tree and the batched mark fallbacks read as easily as the code before items 33 and 109, with no switch only tests use in production signatures and one rule for which runs lose their mark. +### 160. Make the block-text tree and the batched mark fallbacks read as easily as the code before items 33 and 109, with no switch only tests use in production signatures. PR #106's paired ruling read worse than c7bbb04: both seats ruled Locality and Shape worse, the maintainer chose to merge it and file this. Make each place they named easier to read: `writtenText` and `Shape` in `block-text.ts` (a stack walk and eight hand-kept measures), `FallbacksPerPass` threaded through `emitInlineLine` and `reduceInline` only for two properties, -`givesUp` in `line-escaping.ts` and `dropsAlone` in `inline-reduction.ts` deciding one pass's drops -twice, `holdsOnlyRuleCharacters` holding BlockText's rule in the grammar, and `markerOpening` -repeating the marker regexes' `[`. No output may change, and both items' timings must hold. After -item 158, which rewrites the same drop rules. +`holdsOnlyRuleCharacters` holding BlockText's rule in the grammar, and `markerOpening` repeating +the marker regexes' `[`. No output may change, and the timings of items 33, 109 and 158 must hold. ### 150. Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, ` `, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.