10a - the reduction keeps what a reader sees or follows, and names what the document only references
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -31,7 +31,7 @@ export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer
|
||||
const held = nodeLeaves(node, container, [...path, 'content', index], depth)
|
||||
if (!held.ok) return held
|
||||
if (held.value.length === 0) continue
|
||||
const block = isBlockNodeType(node.type)
|
||||
const block = isBlockNodeType(node.type) && node.type !== 'media'
|
||||
if (leaves.length > 0 && (joinsNext || block)) leaves.push(textLeaf(' ', []))
|
||||
joinsNext = block
|
||||
for (const leaf of held.value) leaves.push(leaf)
|
||||
@@ -47,18 +47,47 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP
|
||||
if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container))
|
||||
if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container))
|
||||
if (node.type === 'placeholder') return success([])
|
||||
if (['extension', 'inlineExtension', 'mention', 'status', 'syncBlock'].includes(node.type)) return success(textLeaves(attrs['text'], marks, container))
|
||||
if (['media', 'mediaInline'].includes(node.type)) return success(textLeaves(attrs['alt'], marks, container))
|
||||
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs['url'], marks, container))
|
||||
if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container))
|
||||
if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container))
|
||||
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, nonEmpty(attrs['extensionKey']) ?? 'extension'))
|
||||
if (node.type === 'syncBlock') return success(noteLeaves('synced block'))
|
||||
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container))
|
||||
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container))
|
||||
const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container)
|
||||
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
|
||||
if (!held.ok) return held
|
||||
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
|
||||
}
|
||||
|
||||
function cardLeaves(url: unknown, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
if (typeof url !== 'string') return []
|
||||
return textLeaves(url, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href: url }, type: 'link' }], container)
|
||||
function cardLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const data = attrs['data']
|
||||
const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
|
||||
const url = nonEmpty(attrs['url'])
|
||||
const heldUrl = nonEmpty(held['url'])
|
||||
const name = nonEmpty(held['name'])
|
||||
const href = url ?? heldUrl
|
||||
if (href === undefined) return textLeaves(name, marks, container, 'link card')
|
||||
return linkedLeaves(url ?? name ?? href, href, marks, container)
|
||||
}
|
||||
|
||||
function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container)
|
||||
}
|
||||
|
||||
function idMention(id: unknown): string | undefined {
|
||||
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
|
||||
}
|
||||
|
||||
// An image standing inline is a link to it: CommonMark's inline image reads back as no node.
|
||||
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const alt = nonEmpty(attrs['alt'])
|
||||
const url = nonEmpty(attrs['url'])
|
||||
if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image')
|
||||
return linkedLeaves(alt ?? url, url, marks, container)
|
||||
}
|
||||
|
||||
function noteLeaves(name: string): AdfNode[] {
|
||||
return [textLeaf(`(${name} not included)`, [{ type: 'em' }])]
|
||||
}
|
||||
|
||||
function nonEmpty(value: unknown): string | undefined {
|
||||
@@ -77,8 +106,9 @@ function lineBreak(container: LineContainer): AdfNode {
|
||||
return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', [])
|
||||
}
|
||||
|
||||
function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
if (typeof value !== 'string') return []
|
||||
// note: what the content is named in the note left where it has none.
|
||||
function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer, note?: string): AdfNode[] {
|
||||
if (typeof value !== 'string') return note === undefined ? [] : noteLeaves(note)
|
||||
const text = value.replace(/[\r\u0000]/g, '')
|
||||
const kept = plainMarks(marks, container, text)
|
||||
const leaves: AdfNode[] = []
|
||||
@@ -108,14 +138,24 @@ function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: s
|
||||
|
||||
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||
const attrs = nodeAttrs(mark)
|
||||
const href = attrs['href']
|
||||
const title = attrs['title']
|
||||
const pipes = container === 'table-cell'
|
||||
if (typeof href !== 'string' || spellDestination(href) === undefined || (pipes && href.includes('|'))) return undefined
|
||||
if (typeof title !== 'string' || spellLinkTarget(href, title) === undefined || (pipes && title.includes('|'))) return { attrs: { href }, type: 'link' }
|
||||
const held = attrs['href']
|
||||
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined
|
||||
if (typeof held !== 'string') return undefined
|
||||
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
|
||||
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
|
||||
return { attrs: { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
// spec/flavour.md, Links: the characters no destination spelling holds, then an ampersand an entity reference would read.
|
||||
export function writableHref(href: string): string {
|
||||
let written = href
|
||||
for (const unwritable of [/[\u0000-\u001f\u007f\\<>]/g, /&/g]) {
|
||||
if (spellDestination(written) !== undefined) return written
|
||||
written = written.replace(unwritable, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// The delimiters carry the marks the whole run shares, so they open and close inside them.
|
||||
function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
|
||||
const spelled: AdfNode[] = []
|
||||
@@ -222,6 +262,9 @@ function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback
|
||||
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
|
||||
let last = first
|
||||
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
|
||||
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
|
||||
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
|
||||
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
|
||||
return withoutMark(leaves, first, last, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -172,35 +172,40 @@ test('spells an inline node as its text', () => {
|
||||
assert.equal(plain(paragraph(node('emoji', { shortName: ':tada:', text: '🎉' }), node('emoji', { shortName: ':smile:' }))), '🎉:smile:\n')
|
||||
assert.equal(plain(paragraph(node('date', { timestamp: '1757721600000' }), text(' '), node('date', { timestamp: 'soon' }))), '2025-09-13\n')
|
||||
assert.equal(plain(paragraph({ marks: [strong], ...node('mention', { text: '@Mikael' }) })), '**@Mikael**\n')
|
||||
assert.equal(plain(paragraph(text('by '), node('mention', { id: '5b10a2' }), node('mention', {}))), 'by @5b10a2\n')
|
||||
})
|
||||
|
||||
test('spells a card as a link to its url, dropping one carrying only data', () => {
|
||||
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
|
||||
assert.equal(plain(paragraph(node('inlineCard', { url: 'https://example.com' }))), '<https://example.com>\n')
|
||||
assert.equal(plain(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.com>**\n')
|
||||
assert.equal(plain(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see\n')
|
||||
assert.equal(plain(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
|
||||
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) <https://e.com/u>\n')
|
||||
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
|
||||
assert.equal(plain(node('blockCard', { url: 'https://example.com/a b' })), '[https://example.com/a b](<https://example.com/a b>)\n')
|
||||
assert.equal(plain(node('embedCard', { layout: 'center', url: 'https://example.com' })), '<https://example.com>\n')
|
||||
assert.equal(plain(node('blockCard', { data: {} })), '')
|
||||
assert.equal(plain(node('blockCard', { data: {} })), '_(link card not included)_\n')
|
||||
})
|
||||
|
||||
test('keeps an external image and spells other media as their alt text', () => {
|
||||
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
|
||||
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
|
||||
const caption: AdfNode = node('caption', {}, text('The moon.'))
|
||||
const external = media({ alt: 'Moon', height: 10, type: 'external', url: 'https://example.com/moon.png' })
|
||||
assert.equal(plain(node('mediaSingle', { layout: 'wide', width: 50 }, external, caption)), '\n\nThe moon.\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: '', type: 'external', url: 'https://example.com/a.png' }))), '\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: ' Two\nlines ', type: 'external', url: 'u' }), media({ type: 'external', url: 'v' }))), '\n\n\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b' }))), 'Bad\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b <&>' }))), '\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Photo', collection: 'c', id: 'i', type: 'file' }))), 'Photo\n')
|
||||
assert.equal(plain(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }))), 'One\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), node('mediaInline', { type: 'file' }))), 'a clip\n')
|
||||
assert.equal(plain(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }), external)), 'One\n\n_(image not included)_\n\n\n')
|
||||
assert.equal(plain(external), '\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), text(' '), node('mediaInline', { type: 'file' }))), 'a clip _(image not included)_\n')
|
||||
assert.equal(plain(paragraph(text('See '), external, text(' for '), node('mediaInline', { type: 'external', url: 'https://e.com/i.png' }))), 'See [Moon](https://example.com/moon.png) for <https://e.com/i.png>\n')
|
||||
assert.equal(plain(caption), 'The moon.\n')
|
||||
})
|
||||
|
||||
test('spells an extension as its text attribute and a placeholder as nothing', () => {
|
||||
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'toc' })), 'Contents\n')
|
||||
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '')
|
||||
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), node('placeholder', { text: 'Type here' }))), 'a macro\n')
|
||||
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
|
||||
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
|
||||
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
|
||||
})
|
||||
|
||||
test('spells a node no row names, or one standing where no spelling holds it, as its blocks or its text', () => {
|
||||
@@ -210,8 +215,8 @@ test('spells a node no row names, or one standing where no spelling holds it, as
|
||||
assert.equal(plain(paragraph(text('a '), node('bulletList', {}, item(said('b')), item(said('c'))))), 'a b c\n')
|
||||
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
|
||||
assert.equal(plain(bulletList()), '')
|
||||
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
|
||||
assert.equal(plain(bulletList(item(node('rule', {}), said('x')))), '---\n\nx\n')
|
||||
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '```\na\n \nb\n```\n')
|
||||
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
|
||||
})
|
||||
|
||||
@@ -227,11 +232,14 @@ test('keeps a table as a pipe table headed by its first row, one line per cell',
|
||||
'table',
|
||||
{},
|
||||
row(cell('tableHeader', said('A')), cell('tableHeader', said('B')), cell('tableHeader', said('C'))),
|
||||
row(node('tableCell', { colspan: 2 }, said('wide')), cell('tableCell', said('c'))),
|
||||
row(node('tableCell', { colspan: 2, rowspan: 2 }, said('wide')), cell('tableCell', said('c'))),
|
||||
row(cell('tableCell', said('d'))),
|
||||
row(cell('tableCell')),
|
||||
)
|
||||
assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | c | |\n| | | |\n')
|
||||
assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b x |\n| --- |\n')
|
||||
assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | | c |\n| | | d |\n| | | |\n')
|
||||
const huge = node('table', {}, row(node('tableHeader', { colspan: 1e9, rowspan: 1e9 }, said('A')), cell('tableHeader', said('B'))), row(cell('tableCell', said('c'))))
|
||||
assert.equal(plain(huge), '| A | | | | B |\n| --- | --- | --- | --- | --- |\n| c | | | | |\n')
|
||||
assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b [x](https://e.com/%7C) |\n| --- |\n')
|
||||
assert.equal(plain(node('table', {}, said('stray'))), '| stray |\n| --- |\n')
|
||||
const titled = paragraph(text('t', link('https://e.com', 'a|b')))
|
||||
const folded = [node('expand', { title: 'Log' }, said('x')), node('nestedExpand', {}, said('y'))]
|
||||
@@ -246,10 +254,10 @@ test('keeps code, em, link, strike and strong and drops every other mark, keepin
|
||||
assert.equal(plain(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
|
||||
})
|
||||
|
||||
test('spells a link no CommonMark escape writes as its text', () => {
|
||||
assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }))), 'a b\n')
|
||||
assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'Title')))), '[t](https://e.com) [u](https://e.com "Title")\n')
|
||||
assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '`]: a`\n')
|
||||
test('percent-encodes a link href no CommonMark escape writes until one does', () => {
|
||||
assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&')))), '[a](a%5Cb) b [c](/%26amp;)\n')
|
||||
assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com)\n')
|
||||
assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n')
|
||||
})
|
||||
|
||||
test('drops the mark of a run CommonMark flanking or matching cannot spell', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { commonMarkSpelling, type SpellingMemo } from './adf-to-markdown.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineLeaves, isBlockNodeType, reduceInline } from './plain-inline.ts'
|
||||
import { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
@@ -13,6 +13,8 @@ type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
||||
|
||||
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result<AdfNode[]>
|
||||
|
||||
type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number }
|
||||
|
||||
type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode }
|
||||
|
||||
const alertWords: Readonly<Record<string, string>> = {
|
||||
@@ -35,8 +37,8 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
|
||||
expand: reduceExpand,
|
||||
extension: paragraphOfNode,
|
||||
heading: reduceHeading,
|
||||
media: paragraphOfNode,
|
||||
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => (child.type === 'media' ? imageOrAlt : reduceStanding)(child, childReduction(reduction, index)))),
|
||||
media: reduceMedia,
|
||||
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
|
||||
nestedExpand: reduceExpand,
|
||||
orderedList: reduceList,
|
||||
panel: (node, reduction) => quoted(success([paragraph([text(`[!${alertWord(nodeAttrs(node)['panelType'])}]`)])]), node, reduction),
|
||||
@@ -207,7 +209,17 @@ function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: Ad
|
||||
}
|
||||
|
||||
function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
|
||||
return blocks.ok ? success([{ content: blocks.value, type: 'listItem' }]) : blocks
|
||||
return blocks.ok ? success([itemOf(blocks.value)]) : blocks
|
||||
}
|
||||
|
||||
// A list item holds no line of spaces alone, and a code line of them is what a reader does not see.
|
||||
function itemOf(blocks: readonly AdfNode[]): AdfNode {
|
||||
const content = blocks.map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block))
|
||||
return { content, type: 'listItem' }
|
||||
}
|
||||
|
||||
function blankedLines(code: AdfNode): AdfNode {
|
||||
return code.text === undefined ? code : { ...code, text: code.text.replace(/^[ \t]+$/gm, '') }
|
||||
}
|
||||
|
||||
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
@@ -216,7 +228,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
|
||||
const blocks = taskBlocks(child, childReduction(reduction, index))
|
||||
if (!blocks.ok) return blocks
|
||||
const previous = child.type === 'taskList' ? items.pop() : undefined
|
||||
items.push({ content: previous === undefined ? blocks.value : mergedLists([...nodeContent(previous), ...blocks.value]), type: 'listItem' })
|
||||
items.push(itemOf(previous === undefined ? blocks.value : mergedLists([...nodeContent(previous), ...blocks.value])))
|
||||
}
|
||||
return success(listOf(items, 'bulletList'))
|
||||
}
|
||||
@@ -236,33 +248,71 @@ function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> {
|
||||
}
|
||||
|
||||
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const grid: AdfNode[][] = []
|
||||
const rows: PlacedCell[][] = []
|
||||
for (const [rowIndex, row] of nodeContent(node).entries()) {
|
||||
const rowReduction = childReduction(reduction, rowIndex)
|
||||
const cells = concatenated((row.type === 'tableRow' ? nodeContent(row) : [row]).map((cell, cellIndex) => cellParagraph(cell, childReduction(rowReduction, cellIndex))))
|
||||
if (!cells.ok) return cells
|
||||
grid.push(cells.value)
|
||||
const cells: PlacedCell[] = []
|
||||
for (const [cellIndex, cell] of (row.type === 'tableRow' ? nodeContent(row) : [row]).entries()) {
|
||||
const paragraph = cellParagraph(cell, childReduction(rowReduction, cellIndex))
|
||||
if (!paragraph.ok) return paragraph
|
||||
cells.push({ colspan: span(nodeAttrs(cell)['colspan']), paragraph: paragraph.value, rowspan: span(nodeAttrs(cell)['rowspan']) })
|
||||
}
|
||||
rows.push(cells)
|
||||
}
|
||||
const grid = spannedGrid(rows)
|
||||
const width = grid.reduce((widest, cells) => Math.max(widest, cells.length), 0)
|
||||
const rows = grid.map((cells, rowIndex) => {
|
||||
const padded = [...cells, ...Array.from({ length: width - cells.length }, (): AdfNode => ({ type: 'paragraph' }))]
|
||||
return { content: padded.map((cell): AdfNode => ({ content: [cell], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })), type: 'tableRow' }
|
||||
})
|
||||
return success(width === 0 ? [] : [{ content: rows, type: 'table' }])
|
||||
const tableRows = grid.map((cells, rowIndex) => ({
|
||||
content: Array.from({ length: width }, (_, column): AdfNode => ({ content: [cells[column] ?? { type: 'paragraph' }], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })),
|
||||
type: 'tableRow',
|
||||
}))
|
||||
return success(width === 0 ? [] : [{ content: tableRows, type: 'table' }])
|
||||
}
|
||||
|
||||
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
function span(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 1 ? value : 1
|
||||
}
|
||||
|
||||
// A span keeps its cell under its header by empty cells where it covered; they number no more than the table's cells.
|
||||
function spannedGrid(rows: readonly PlacedCell[][]): (AdfNode | undefined)[][] {
|
||||
const grid: (AdfNode | undefined)[][] = rows.map(() => [])
|
||||
const covered = rows.map(() => new Set<number>())
|
||||
let padding = rows.reduce((count, cells) => count + cells.length, 0)
|
||||
for (const [rowIndex, cells] of rows.entries()) {
|
||||
let column = 0
|
||||
for (const cell of cells) {
|
||||
while (covered[rowIndex]?.has(column) === true) column += 1
|
||||
setCell(grid, rowIndex, column, cell.paragraph)
|
||||
for (let row = rowIndex; row < Math.min(rows.length, rowIndex + cell.rowspan) && padding > 0; row += 1) {
|
||||
for (let spanned = row === rowIndex ? 1 : 0; spanned < cell.colspan && padding > 0; spanned += 1) {
|
||||
covered[row]?.add(column + spanned)
|
||||
setCell(grid, row, column + spanned, { type: 'paragraph' })
|
||||
padding -= 1
|
||||
}
|
||||
}
|
||||
column += 1
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
function setCell(grid: (AdfNode | undefined)[][], row: number, column: number, cell: AdfNode): void {
|
||||
const cells = grid[row]
|
||||
if (cells !== undefined && cells[column] === undefined) cells[column] = cell
|
||||
}
|
||||
|
||||
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<AdfNode> {
|
||||
const blocks = cell.type === 'tableCell' || cell.type === 'tableHeader' ? nodeContent(cell) : [cell]
|
||||
const content = reduceInline(blocks, 'table-cell', reduction.path, reduction.depth)
|
||||
return content.ok ? success([content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)]) : content
|
||||
return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content
|
||||
}
|
||||
|
||||
function imageOrAlt(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const attrs = nodeAttrs(media)
|
||||
const url = attrs['url']
|
||||
if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction)
|
||||
const held = attrs['alt']
|
||||
const alt = typeof held === 'string' ? held.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ').trim() : ''
|
||||
const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }], type: 'mediaSingle' }
|
||||
return commonMarkSpelling(image, reduction.path, reduction.depth, reduction.memo)?.ok === true ? success([image]) : paragraphOfNode(media, reduction)
|
||||
const external: AdfNode = { attrs: alt === '' ? { type: 'external', url: writableHref(url) } : { alt, type: 'external', url: writableHref(url) }, type: 'media' }
|
||||
const image: AdfNode = { attrs: { layout: 'center' }, content: [external], type: 'mediaSingle' }
|
||||
return success([image])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user