feat(core): moving in affine-reader doc parsers (#12840)

fix AI-191

#### PR Dependency Tree


* **PR #12840** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced the ability to convert rich text documents into Markdown,
supporting a wide range of content types such as headings, lists,
tables, images, code blocks, attachments, and embedded documents.
- Added support for parsing collaborative document structures and
rendering them as structured Markdown or parsed representations.
- Enhanced handling of database and table blocks, including conversion
to Markdown tables with headers and cell content.

- **Documentation**
  - Added a README noting the use of a forked Markdown converter.

- **Tests**
  - Added new test coverage for document parsing features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #12840** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
Peng Xiao
2025-06-17 16:32:11 +08:00
committed by GitHub
parent dfe4c22a75
commit f4c20056a0
11 changed files with 1428 additions and 0 deletions
@@ -0,0 +1 @@
A fork of https://github.com/frysztak/quill-delta-to-markdown
@@ -0,0 +1,95 @@
// eslint-disable
// @ts-nocheck
import { Node } from './utils/node';
import { encodeLink } from './utils/url';
export interface InlineReference {
type: 'LinkedPage';
pageId: string;
title?: string;
params?: { mode: 'doc' | 'edgeless' };
}
export interface ConverterOptions {
convertInlineReferenceLink?: (reference: InlineReference) => {
title: string;
link: string;
};
}
const defaultConvertInlineReferenceLink = (reference: InlineReference) => {
return {
title: reference.title || '',
link: [reference.type, reference.pageId, reference.params?.mode]
.filter(Boolean)
.join(':'),
};
};
export function getConverters(opts: ConverterOptions = {}) {
const { convertInlineReferenceLink = defaultConvertInlineReferenceLink } =
opts;
return {
embed: {
image: function (src) {
this.append('![](' + encodeLink(src) + ')');
},
// Not a default Quill feature, converts custom divider embed blot added when
// creating quill editor instance.
// See https://quilljs.com/guides/cloning-medium-with-parchment/#dividers
thematic_break: function () {
this.open = '\n---\n' + this.open;
},
},
inline: {
italic: function () {
return ['_', '_'];
},
bold: function () {
return ['**', '**'];
},
link: function (url) {
return ['[', '](' + url + ')'];
},
reference: function (reference: InlineReference) {
const { title, link } = convertInlineReferenceLink(reference);
return ['[', `${title}](${link})`];
},
strike: function () {
return ['~~', '~~'];
},
code: function () {
return ['`', '`'];
},
},
block: {
header: function ({ header }) {
this.open = '#'.repeat(header) + ' ' + this.open;
},
blockquote: function () {
this.open = '> ' + this.open;
},
list: {
group: function () {
return new Node(['', '\n']);
},
line: function (attrs, group) {
if (attrs.list === 'bullet') {
this.open = '- ' + this.open;
} else if (attrs.list === 'checked') {
this.open = '- [x] ' + this.open;
} else if (attrs.list === 'unchecked') {
this.open = '- [ ] ' + this.open;
} else if (attrs.list === 'ordered') {
group.count = group.count || 0;
var count = ++group.count;
this.open = count + '. ' + this.open;
}
},
},
},
};
}
@@ -0,0 +1,147 @@
// eslint-disable
// @ts-nocheck
import { Node } from './utils/node';
export const deltaToMd = (delta, converters) => {
return convert(delta, converters).render().trimEnd() + '\n';
};
function convert(ops, converters) {
let group, line, el, activeInline, beginningOfLine;
let root = new Node();
function newLine() {
el = line = new Node(['', '\n']);
root.append(line);
activeInline = {};
}
newLine();
for (let i = 0; i < ops.length; i++) {
let op = ops[i];
if (typeof op.insert === 'object') {
for (let k in op.insert) {
if (converters.embed[k]) {
applyInlineAttributes(op.attributes);
converters.embed[k].call(el, op.insert[k], op.attributes);
}
}
} else {
let lines = op.insert.split('\n');
if (hasBlockLevelAttribute(op.attributes, converters)) {
// Some line-level styling (ie headings) is applied by inserting a \n
// with the style; the style applies back to the previous \n.
// There *should* only be one style in an insert operation.
for (let j = 1; j < lines.length; j++) {
for (let attr in op.attributes) {
if (converters.block[attr]) {
let fn = converters.block[attr];
if (typeof fn === 'object') {
if (group && group.type !== attr) {
group = null;
}
if (!group && fn.group) {
group = {
el: fn.group(),
type: attr,
value: op.attributes[attr],
distance: 0,
};
root.append(group.el);
}
if (group) {
group.el.append(line);
group.distance = 0;
}
fn = fn.line;
}
fn.call(line, op.attributes, group);
newLine();
break;
}
}
}
beginningOfLine = true;
} else {
for (let l = 0; l < lines.length; l++) {
if ((l > 0 || beginningOfLine) && group && ++group.distance >= 2) {
group = null;
}
applyInlineAttributes(
op.attributes,
ops[i + 1] && ops[i + 1].attributes
);
el.append(lines[l]);
if (l < lines.length - 1) {
newLine();
}
}
beginningOfLine = false;
}
}
}
return root;
function applyInlineAttributes(attrs, next?: any) {
let first: any[] = [];
let then: any[] = [];
attrs = attrs || {};
let tag = el,
seen = {};
while (tag._format) {
seen[tag._format] = true;
if (!attrs[tag._format] || tag.open !== tag.close) {
for (let k in seen) {
delete activeInline[k];
}
el = tag.parent();
}
tag = tag.parent();
}
for (let attr in attrs) {
if (converters.inline[attr] && attrs[attr]) {
if (activeInline[attr] && activeInline[attr] === attrs[attr]) {
continue; // do nothing -- we should already be inside this style's tag
}
if (next && attrs[attr] === next[attr]) {
first.push(attr); // if the next operation has the same style, this should be the outermost tag
} else {
then.push(attr);
}
activeInline[attr] = attrs[attr];
}
}
first.forEach(apply);
then.forEach(apply);
function apply(fmt) {
let newEl = converters.inline[fmt].call(null, attrs[fmt]);
if (Array.isArray(newEl)) {
newEl = new Node(newEl);
}
newEl._format = fmt;
el.append(newEl);
el = newEl;
}
}
}
function hasBlockLevelAttribute(attrs, converters) {
for (let k in attrs) {
if (Object.keys(converters.block).includes(k)) {
return true;
}
}
return false;
}
@@ -0,0 +1,2 @@
export { getConverters } from './delta-converters';
export { deltaToMd } from './delta-to-md';
@@ -0,0 +1,66 @@
// eslint-disable
// @ts-nocheck
let id = 0;
export class Node {
id = ++id;
children: Node[];
open: string;
close: string;
text: string;
_format: string;
_parent: Node;
constructor(data?: string[] | string) {
if (Array.isArray(data)) {
this.open = data[0];
this.close = data[1];
} else if (typeof data === 'string') {
this.text = data;
}
this.children = [];
}
append(e: Node) {
if (!(e instanceof Node)) {
e = new Node(e);
}
if (e._parent) {
const idx = e._parent.children.indexOf(e);
e._parent.children.splice(idx, 1);
}
e._parent = this;
this.children = this.children.concat(e);
}
render() {
const inner =
(this.text || '') + this.children.map(c => c.render()).join('');
if (
inner.trim() === '' &&
this.open === this.close &&
this.open &&
this.close
) {
return '';
}
const wrapped = this.open && this.close;
const emptyInner = inner.trim() === '';
const fragments = [
inner.startsWith(' ') && !emptyInner && wrapped ? ' ' : '',
this.open,
wrapped ? inner.trim() : inner,
this.close,
inner.endsWith(' ') && !emptyInner && wrapped ? ' ' : '',
].filter(f => f);
return fragments.join('');
}
parent() {
return this._parent;
}
}
@@ -0,0 +1,5 @@
export const encodeLink = (link: string) =>
encodeURI(link)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/(\?|&)response-content-disposition=attachment.*$/, '');