mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Emit a line mixing em, strike and strong in time linear in its mark runs
This commit is contained in:
@@ -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'],
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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<number>; 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<string> {
|
||||
@@ -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<PortableLineFallback | undefined> {
|
||||
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, alone: (run: MarkRun) => boolean): Result<PortableLineFallback | undefined> {
|
||||
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<LinePass> {
|
||||
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] }
|
||||
|
||||
@@ -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<number>
|
||||
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<number>()
|
||||
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<number>, { 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 {
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -220,7 +220,7 @@ function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index
|
||||
|
||||
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath, perPass: FallbacksPerPass): Result<AdfNode[]> {
|
||||
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<number, number>()
|
||||
for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth)
|
||||
const depths = new Map<number, number[]>()
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user