@inshapardaz/likhari-converters 0.1.45 → 0.1.46-dev.344

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ import type { SerializedEditorState } from 'lexical';
2
+ import { type FormatRegistry } from '../registry.js';
3
+ import type { ConverterContext, FormatId } from '../types.js';
4
+ export interface ConvertContentOptions {
5
+ /** The format `content` is in. */
6
+ inputFormat: FormatId;
7
+ /** The format to write. */
8
+ outputFormat: FormatId;
9
+ /** Passed to the parse and the serialize (image URL resolver and the like). */
10
+ context?: ConverterContext;
11
+ /** Defaults to the registry with all four built-in formats. */
12
+ registry?: FormatRegistry;
13
+ }
14
+ /** Reads `content` and returns it as Lexical state. */
15
+ export declare function convertContentToState(content: string, options: Omit<ConvertContentOptions, 'outputFormat'>): SerializedEditorState;
16
+ /**
17
+ * Converts a document from one format to another through the editor's own parsers and
18
+ * serializers, so the result is exactly what the editor would save in that format. Needs no
19
+ * editor or DOM for Markdown, Lexical JSON and plain text (HTML input uses `DOMParser`).
20
+ *
21
+ * Going to a format that holds less than the input (rich text to plain text, a table to
22
+ * Markdown, ...) degrades the way the format's fidelity matrix says; see
23
+ * `docs/lexical-editor-spec.md` and `docs/markdown-dialect.md`.
24
+ */
25
+ export declare function convertContent(content: string, options: ConvertContentOptions): string;
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/convert/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAE3D,MAAM,WAAW,qBAAqB;IACpC,kCAAkC;IAClC,WAAW,EAAE,QAAQ,CAAC;IACtB,2BAA2B;IAC3B,YAAY,EAAE,QAAQ,CAAC;IACvB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,cAAc,CAAC;CAC3B;AAED,uDAAuD;AACvD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,qBAAqB,EAAE,cAAc,CAAC,GAAG,qBAAqB,CAQlI;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,GAAG,MAAM,CAGtF"}
@@ -0,0 +1,25 @@
1
+ import { defaultFormatRegistry } from '../registry.js';
2
+ /** Reads `content` and returns it as Lexical state. */
3
+ export function convertContentToState(content, options) {
4
+ const { inputFormat, context, registry = defaultFormatRegistry } = options;
5
+ try {
6
+ return registry.parse(inputFormat, content, context);
7
+ }
8
+ catch (error) {
9
+ const reason = error instanceof Error ? error.message : String(error);
10
+ throw new Error(`convertContent: the input is not valid ${inputFormat}: ${reason}`);
11
+ }
12
+ }
13
+ /**
14
+ * Converts a document from one format to another through the editor's own parsers and
15
+ * serializers, so the result is exactly what the editor would save in that format. Needs no
16
+ * editor or DOM for Markdown, Lexical JSON and plain text (HTML input uses `DOMParser`).
17
+ *
18
+ * Going to a format that holds less than the input (rich text to plain text, a table to
19
+ * Markdown, ...) degrades the way the format's fidelity matrix says; see
20
+ * `docs/lexical-editor-spec.md` and `docs/markdown-dialect.md`.
21
+ */
22
+ export function convertContent(content, options) {
23
+ const registry = options.registry ?? defaultFormatRegistry;
24
+ return registry.serialize(options.outputFormat, convertContentToState(content, options), options.context);
25
+ }
package/dist/index.d.ts CHANGED
@@ -4,4 +4,6 @@ export { plainTextConverter } from './plain-text/index.js';
4
4
  export { lexicalJsonConverter } from './lexical-json/index.js';
5
5
  export { markdownConverter } from './markdown/index.js';
6
6
  export { htmlConverter } from './html/index.js';
7
+ export { mergePages, mergePagesToState, type MergePagesOptions, type PageInput } from './merge/index.js';
8
+ export { convertContent, convertContentToState, type ConvertContentOptions } from './convert/index.js';
7
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,KAAK,iBAAiB,EAAE,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AAChG,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,KAAK,qBAAqB,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -4,3 +4,5 @@ export { plainTextConverter } from './plain-text/index.js';
4
4
  export { lexicalJsonConverter } from './lexical-json/index.js';
5
5
  export { markdownConverter } from './markdown/index.js';
6
6
  export { htmlConverter } from './html/index.js';
7
+ export { mergePages, mergePagesToState } from './merge/index.js';
8
+ export { convertContent, convertContentToState } from './convert/index.js';
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/markdown/parse.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAmUjD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,qBAAqB,CAO3F"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/markdown/parse.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAoUjD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,qBAAqB,CAO3F"}
@@ -264,6 +264,8 @@ function blocks(nodes, extra = {}) {
264
264
  case 'leafDirective': {
265
265
  if (node.name === 'pagebreak')
266
266
  out.push({ type: 'page-break', version: 1 });
267
+ else if (node.name === 'blank')
268
+ out.push(paragraph());
267
269
  else if (node.name === 'figure') {
268
270
  const attrs = node.attributes ?? {};
269
271
  const src = safeImageSrc(attrs.src ?? '');
@@ -302,7 +304,7 @@ function restoreUnknownDirectives(node, source) {
302
304
  node.children = node.children.map((child) => {
303
305
  const c = child;
304
306
  const unknownText = c.type === 'textDirective' && !KNOWN_TEXT_DIRECTIVES.has(c.name ?? '');
305
- const unknownLeaf = c.type === 'leafDirective' && c.name !== 'pagebreak' && c.name !== 'figure';
307
+ const unknownLeaf = c.type === 'leafDirective' && c.name !== 'pagebreak' && c.name !== 'blank' && c.name !== 'figure';
306
308
  if ((unknownText || unknownLeaf) && c.position?.start.offset !== undefined && c.position.end.offset !== undefined) {
307
309
  const raw = source.slice(c.position.start.offset, c.position.end.offset);
308
310
  return unknownLeaf ? { type: 'paragraph', children: [{ type: 'text', value: raw }] } : { type: 'text', value: raw };
@@ -1 +1 @@
1
- {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/markdown/serialize.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAKjD,OAAO,EAAa,KAAK,KAAK,EAAe,MAAM,SAAS,CAAC;AA6C7D,2GAA2G;AAC3G,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,CAWjD;AAqHD,wFAAwF;AACxF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAMhE;AAuJD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,qBAAqB,EAAE,GAAG,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAM9F"}
1
+ {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/markdown/serialize.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAKjD,OAAO,EAAa,KAAK,KAAK,EAAe,MAAM,SAAS,CAAC;AA6C7D,2GAA2G;AAC3G,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,CAWjD;AAqHD,wFAAwF;AACxF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAMhE;AAuJD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,qBAAqB,EAAE,GAAG,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAc9F"}
@@ -2,7 +2,7 @@ import { unified } from 'unified';
2
2
  import remarkStringify from 'remark-stringify';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import remarkDirective from 'remark-directive';
5
- import { FORMAT_BOLD, FORMAT_CODE, FORMAT_HIGHLIGHT, FORMAT_ITALIC, FORMAT_STRIKETHROUGH, FORMAT_SUBSCRIPT, FORMAT_SUPERSCRIPT, FORMAT_UNDERLINE, rootChildren, } from '../shared/serialized.js';
5
+ import { FORMAT_BOLD, FORMAT_CODE, FORMAT_HIGHLIGHT, FORMAT_ITALIC, FORMAT_STRIKETHROUGH, FORMAT_SUBSCRIPT, FORMAT_SUPERSCRIPT, FORMAT_UNDERLINE, isEmptyParagraph, rootChildren, } from '../shared/serialized.js';
6
6
  import { directive } from './mdast.js';
7
7
  import { layoutOrDefault, scaleOrDefault } from '../shared/poetry.js';
8
8
  import { nodeToHtml, setFootnoteOrder } from '../html/serialize.js';
@@ -333,7 +333,14 @@ function blockToMd(node, ctx) {
333
333
  export function serializeMarkdown(state, ctx) {
334
334
  const blocks = rootChildren(state);
335
335
  setFootnoteOrder(blocks);
336
- const tree = { type: 'root', children: blocks.flatMap((n) => blockToMd(n, ctx)) };
336
+ // Markdown has no empty paragraph, so a run of them at the end of the document (which marks where a
337
+ // page's last paragraph ends, see mergePages) is written as `::blank` lines to survive a save.
338
+ let end = blocks.length;
339
+ while (end > 0 && (isEmptyParagraph(blocks[end - 1]) || blocks[end - 1].type === 'footnote-list'))
340
+ end -= 1;
341
+ // A document that is nothing but empty paragraphs is still just empty (end === 0).
342
+ const children = blocks.flatMap((n, i) => end > 0 && i >= end && isEmptyParagraph(n) ? [directive('leafDirective', 'blank', {})] : blockToMd(n, ctx));
343
+ const tree = { type: 'root', children };
337
344
  // remark-stringify consumes mdast; our MdNode is a structural subset of it.
338
345
  return String(processor.stringify(tree)).replace(/\n+$/, '\n').replace(/^\n+$/, '');
339
346
  }
@@ -0,0 +1,23 @@
1
+ import { type SNode } from '../shared/serialized.js';
2
+ /**
3
+ * Whether a block's text stops mid-sentence: it does not end in a sentence mark (once any
4
+ * closing quote or bracket is set aside). A comma, colon, semicolon, dash or a plain word all
5
+ * count as unfinished. A block with no text at all counts as finished.
6
+ */
7
+ export declare function endsMidSentence(block: SNode): boolean;
8
+ /**
9
+ * Whether two blocks could be one paragraph: both are plain paragraphs, the second is not
10
+ * indented (an indent starts a new paragraph) and has text, and they agree on text direction
11
+ * and alignment. A direction left unset on either side is compatible with anything.
12
+ */
13
+ export declare function canJoinParagraphs(previous: SNode | undefined, next: SNode | undefined): boolean;
14
+ /**
15
+ * Whether a page's text ends with an empty line, which marks the end of a paragraph: a trailing
16
+ * empty paragraph in the parsed page (Markdown writes one as `::blank`). Markdown written by hand
17
+ * may instead just end with a blank line (`\n\n`, not only a final newline), which the parser
18
+ * would drop, so for Markdown the raw text is checked too.
19
+ */
20
+ export declare function endsWithBlankLine(rawFormat: string, rawText: string, blocks: SNode[]): boolean;
21
+ /** Appends `next`'s content to `previous` (which is changed), separated by one space unless one is already there. */
22
+ export declare function joinParagraphs(previous: SNode, next: SNode): void;
23
+ //# sourceMappingURL=continuation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"continuation.d.ts","sourceRoot":"","sources":["../../src/merge/continuation.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8B,KAAK,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAc9E;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAGrD;AAKD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,KAAK,GAAG,SAAS,EAAE,IAAI,EAAE,KAAK,GAAG,SAAS,GAAG,OAAO,CAM/F;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,OAAO,CAG9F;AAKD,qHAAqH;AACrH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,GAAG,IAAI,CAOjE"}
@@ -0,0 +1,63 @@
1
+ import { isEmptyParagraph, textNode } from '../shared/serialized.js';
2
+ /** Marks that end a sentence, in Urdu and Latin script: ۔ ؟ ! ? . … and the CJK full stop. */
3
+ const SENTENCE_END = /[۔؟!?.…。]/;
4
+ /** Closing quotes and brackets that may follow the mark and still end the sentence: ” ’ " ' ) ] » */
5
+ const CLOSERS = /[”’"')\]»]+$/;
6
+ /** The text of a block, from its text nodes only. */
7
+ function plainText(node) {
8
+ if (node.type === 'text')
9
+ return String(node.text ?? '');
10
+ if (node.type === 'linebreak')
11
+ return '\n';
12
+ return (node.children ?? []).map(plainText).join('');
13
+ }
14
+ /**
15
+ * Whether a block's text stops mid-sentence: it does not end in a sentence mark (once any
16
+ * closing quote or bracket is set aside). A comma, colon, semicolon, dash or a plain word all
17
+ * count as unfinished. A block with no text at all counts as finished.
18
+ */
19
+ export function endsMidSentence(block) {
20
+ const text = plainText(block).trimEnd().replace(CLOSERS, '').trimEnd();
21
+ return text !== '' && !SENTENCE_END.test(text.charAt(text.length - 1));
22
+ }
23
+ /** The alignment a paragraph effectively has: '' and 'start' are the same thing. */
24
+ const alignment = (block) => (block.format === '' || block.format == null ? 'start' : String(block.format));
25
+ /**
26
+ * Whether two blocks could be one paragraph: both are plain paragraphs, the second is not
27
+ * indented (an indent starts a new paragraph) and has text, and they agree on text direction
28
+ * and alignment. A direction left unset on either side is compatible with anything.
29
+ */
30
+ export function canJoinParagraphs(previous, next) {
31
+ if (!previous || !next || previous.type !== 'paragraph' || next.type !== 'paragraph')
32
+ return false;
33
+ if (plainText(next).trim() === '' || plainText(previous).trim() === '')
34
+ return false;
35
+ if (Number(next.indent ?? 0) > 0)
36
+ return false;
37
+ if (previous.direction && next.direction && previous.direction !== next.direction)
38
+ return false;
39
+ return alignment(previous) === alignment(next);
40
+ }
41
+ /**
42
+ * Whether a page's text ends with an empty line, which marks the end of a paragraph: a trailing
43
+ * empty paragraph in the parsed page (Markdown writes one as `::blank`). Markdown written by hand
44
+ * may instead just end with a blank line (`\n\n`, not only a final newline), which the parser
45
+ * would drop, so for Markdown the raw text is checked too.
46
+ */
47
+ export function endsWithBlankLine(rawFormat, rawText, blocks) {
48
+ if (isEmptyParagraph(blocks[blocks.length - 1]))
49
+ return true;
50
+ return rawFormat === 'markdown' && /(\r?\n[ \t]*){2,}$/.test(rawText);
51
+ }
52
+ const startsWithSpace = (children) => /^\s/.test(plainText({ type: 'x', children: children.slice(0, 1) }));
53
+ const endsWithSpace = (children) => /\s$/.test(plainText({ type: 'x', children: children.slice(-1) }));
54
+ /** Appends `next`'s content to `previous` (which is changed), separated by one space unless one is already there. */
55
+ export function joinParagraphs(previous, next) {
56
+ const head = previous.children ?? [];
57
+ const tail = next.children ?? [];
58
+ const needsSpace = head.length > 0 && tail.length > 0 && !endsWithSpace(head) && !startsWithSpace(tail);
59
+ previous.children = [...head, ...(needsSpace ? [textNode(' ')] : []), ...tail];
60
+ // The merged paragraph keeps the first one's direction unless it had none.
61
+ if (!previous.direction && next.direction)
62
+ previous.direction = next.direction;
63
+ }
@@ -0,0 +1,51 @@
1
+ import type { SerializedEditorState } from 'lexical';
2
+ import { type FormatRegistry } from '../registry.js';
3
+ import type { ConverterContext, FormatId } from '../types.js';
4
+ /** One page: its text, plus its format when that differs from `MergePagesOptions.inputFormat`. */
5
+ export type PageInput = string | {
6
+ content: string;
7
+ format?: FormatId;
8
+ };
9
+ export interface MergePagesOptions {
10
+ /** The format of every page given as a bare string. Defaults to 'markdown'. */
11
+ inputFormat?: FormatId;
12
+ /** The format of the combined document. */
13
+ outputFormat: FormatId;
14
+ /** Leave out pages with no content: nothing, whitespace, or only empty paragraphs. Off by default. */
15
+ skipBlankPages?: boolean;
16
+ /**
17
+ * Join pages that carry on the previous page's last paragraph into one paragraph, instead
18
+ * of breaking the page. Off by default. Both modes only join when the next page opens with an
19
+ * unindented paragraph of the same direction and alignment after a plain paragraph; headings,
20
+ * lists, quotes, tables and blank pages never join.
21
+ *
22
+ * - `'blank-line'`: a page that ends with an empty line or empty paragraph ends its paragraph,
23
+ * so the next page starts a new one; a page that does not, continues. The marker is consumed,
24
+ * so it does not appear in the output. Markdown is read from the raw text (it must end with a
25
+ * blank line, `\n\n`), because Markdown has no empty paragraph; see the README.
26
+ * - `'punctuation'`: a guess from the text. The page continues when its last paragraph does
27
+ * not end with `۔ ؟ ! ? . …` (closing quotes and brackets aside).
28
+ *
29
+ * A page break cannot sit inside a paragraph, so none is inserted where pages are joined.
30
+ */
31
+ joinParagraphs?: 'blank-line' | 'punctuation';
32
+ /** Passed to every parse and to the final serialize (image URL resolver and the like). */
33
+ context?: ConverterContext;
34
+ /** Defaults to the registry with all four built-in formats. */
35
+ registry?: FormatRegistry;
36
+ }
37
+ /** Parses each page and returns the combined document as Lexical state, without serializing it. */
38
+ export declare function mergePagesToState(pages: PageInput[], options?: Omit<MergePagesOptions, 'outputFormat'>): SerializedEditorState;
39
+ /**
40
+ * Merges pages (Markdown, Lexical JSON, or any other supported format) into one document,
41
+ * with a page break between each pair of pages, in the requested output format. Needs no
42
+ * editor or DOM, so it can run in a server, a worker or a build script.
43
+ *
44
+ * With `skipBlankPages`, blank pages are left out and no break is added for them.
45
+ *
46
+ * Footnote ids are only unique within a page, so a page whose footnote ids clash with an
47
+ * earlier page's gets them renamed (`1` becomes `1-2`), in references and notes alike; the
48
+ * notes of all pages end up in one list at the end of the document.
49
+ */
50
+ export declare function mergePages(pages: PageInput[], options: MergePagesOptions): string;
51
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/merge/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAI3D,kGAAkG;AAClG,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,QAAQ,CAAA;CAAE,CAAC;AAExE,MAAM,WAAW,iBAAiB;IAChC,+EAA+E;IAC/E,WAAW,CAAC,EAAE,QAAQ,CAAC;IACvB,2CAA2C;IAC3C,YAAY,EAAE,QAAQ,CAAC;IACvB,sGAAsG;IACtG,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;;;;;OAcG;IACH,cAAc,CAAC,EAAE,YAAY,GAAG,aAAa,CAAC;IAC9C,0FAA0F;IAC1F,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,cAAc,CAAC;CAC3B;AAED,mGAAmG;AACnG,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,OAAO,GAAE,IAAI,CAAC,iBAAiB,EAAE,cAAc,CAAM,GAAG,qBAAqB,CA8ClI;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAGjF"}
@@ -0,0 +1,95 @@
1
+ import { defaultFormatRegistry } from '../registry.js';
2
+ import { elementBase, isEmptyParagraph, makeState, rootChildren } from '../shared/serialized.js';
3
+ import { canJoinParagraphs, endsMidSentence, endsWithBlankLine, joinParagraphs } from './continuation.js';
4
+ /** Parses each page and returns the combined document as Lexical state, without serializing it. */
5
+ export function mergePagesToState(pages, options = {}) {
6
+ const { inputFormat = 'markdown', skipBlankPages = false, joinParagraphs: joinMode, context, registry = defaultFormatRegistry } = options;
7
+ const body = [];
8
+ const footnotes = [];
9
+ const takenFootnoteIds = new Set();
10
+ let kept = 0;
11
+ let previousEndedParagraph = false;
12
+ pages.forEach((page, index) => {
13
+ const { content, format = inputFormat } = typeof page === 'string' ? { content: page } : page;
14
+ let blocks;
15
+ try {
16
+ blocks = structuredClone(rootChildren(registry.parse(format, content, context)));
17
+ }
18
+ catch (error) {
19
+ const reason = error instanceof Error ? error.message : String(error);
20
+ throw new Error(`mergePages: page ${index + 1} is not valid ${format}: ${reason}`);
21
+ }
22
+ if (skipBlankPages && blocks.every(isBlank))
23
+ return;
24
+ // Each page brings its own footnote list; they are combined into one at the end.
25
+ const pageLists = blocks.filter((block) => block.type === 'footnote-list');
26
+ const pageBody = blocks.filter((block) => block.type !== 'footnote-list');
27
+ uniquifyFootnoteIds([...pageBody, ...pageLists], takenFootnoteIds);
28
+ for (const list of pageLists)
29
+ footnotes.push(...(list.children ?? []));
30
+ const previous = body[body.length - 1];
31
+ let continues = false;
32
+ if (joinMode === 'punctuation')
33
+ continues = !!previous && endsMidSentence(previous);
34
+ if (joinMode === 'blank-line') {
35
+ continues = !previousEndedParagraph;
36
+ previousEndedParagraph = endsWithBlankLine(format, content, [...pageBody]);
37
+ // The empty line only marks the end of the paragraph; it is not content to keep.
38
+ if (pageBody.some((block) => !isEmptyParagraph(block))) {
39
+ while (isEmptyParagraph(pageBody[pageBody.length - 1]))
40
+ pageBody.pop();
41
+ }
42
+ }
43
+ const joins = kept > 0 && continues && canJoinParagraphs(previous, pageBody[0]);
44
+ if (joins)
45
+ joinParagraphs(previous, pageBody.shift());
46
+ else if (kept > 0)
47
+ body.push({ type: 'page-break', version: 1 });
48
+ kept += 1;
49
+ body.push(...pageBody);
50
+ });
51
+ return makeState(footnotes.length > 0 ? [...body, elementBase('footnote-list', footnotes)] : body);
52
+ }
53
+ /**
54
+ * Merges pages (Markdown, Lexical JSON, or any other supported format) into one document,
55
+ * with a page break between each pair of pages, in the requested output format. Needs no
56
+ * editor or DOM, so it can run in a server, a worker or a build script.
57
+ *
58
+ * With `skipBlankPages`, blank pages are left out and no break is added for them.
59
+ *
60
+ * Footnote ids are only unique within a page, so a page whose footnote ids clash with an
61
+ * earlier page's gets them renamed (`1` becomes `1-2`), in references and notes alike; the
62
+ * notes of all pages end up in one list at the end of the document.
63
+ */
64
+ export function mergePages(pages, options) {
65
+ const registry = options.registry ?? defaultFormatRegistry;
66
+ return registry.serialize(options.outputFormat, mergePagesToState(pages, options), options.context);
67
+ }
68
+ /** A block with nothing in it: a paragraph holding only whitespace and line breaks. Anything else (an image, a rule, a table, a break) is content. */
69
+ function isBlank(block) {
70
+ if (block.type !== 'paragraph')
71
+ return false;
72
+ return (block.children ?? []).every((child) => (child.type === 'text' ? String(child.text ?? '').trim() === '' : child.type === 'linebreak'));
73
+ }
74
+ /** Renames this page's footnote ids that an earlier page already uses, then claims all its ids. */
75
+ function uniquifyFootnoteIds(blocks, taken) {
76
+ const nodes = [];
77
+ const collect = (node) => {
78
+ if (typeof node.footnoteId === 'string')
79
+ nodes.push(node);
80
+ (node.children ?? []).forEach(collect);
81
+ };
82
+ blocks.forEach(collect);
83
+ const renamed = new Map();
84
+ for (const node of nodes) {
85
+ const id = node.footnoteId;
86
+ if (!renamed.has(id)) {
87
+ let next = id;
88
+ for (let n = 2; taken.has(next); n += 1)
89
+ next = `${id}-${n}`;
90
+ renamed.set(id, next);
91
+ }
92
+ node.footnoteId = renamed.get(id);
93
+ }
94
+ renamed.forEach((id) => taken.add(id));
95
+ }
@@ -33,4 +33,6 @@ export declare function isInlineNode(node: SNode): boolean;
33
33
  * order, among all such nodes. Walks the whole tree once so every converter
34
34
  * numbers references and their matching footnote-list entries identically. */
35
35
  export declare function collectFootnoteOrder(blocks: SNode[]): Map<string, number>;
36
+ /** A paragraph with nothing in it: no children, or only whitespace and line breaks. */
37
+ export declare function isEmptyParagraph(node: SNode | undefined): boolean;
36
38
  //# sourceMappingURL=serialized.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"serialized.d.ts","sourceRoot":"","sources":["../../src/shared/serialized.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAErD;;;;GAIG;AACH,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,EAAE,CAAC;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,oBAAoB,IAAI,CAAC;AACtC,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,WAAW,KAAK,CAAC;AAC9B,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,6DAA6D;AAC7D,eAAO,MAAM,SAAS,KAAK,CAAC;AAE5B,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAAC;AAE7C,wBAAgB,YAAY,CAAC,KAAK,EAAE,qBAAqB,GAAG,KAAK,EAAE,CAElE;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAK,GAAG,KAAK,CAEpE;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,KAAK,CAEvG;AAED,wBAAgB,SAAS,CAAC,QAAQ,GAAE,KAAK,EAAO,EAAE,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,KAAK,CAE5F;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,qBAAqB,CAKhE;AAED,qDAAqD;AACrD,wBAAgB,YAAY,CAAC,IAAI,EAAE,KAAK,GAAG,OAAO,CAEjD;AAED;;;8EAG8E;AAC9E,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAUzE"}
1
+ {"version":3,"file":"serialized.d.ts","sourceRoot":"","sources":["../../src/shared/serialized.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAErD;;;;GAIG;AACH,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,EAAE,CAAC;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,oBAAoB,IAAI,CAAC;AACtC,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,WAAW,KAAK,CAAC;AAC9B,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,6DAA6D;AAC7D,eAAO,MAAM,SAAS,KAAK,CAAC;AAE5B,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAAC;AAE7C,wBAAgB,YAAY,CAAC,KAAK,EAAE,qBAAqB,GAAG,KAAK,EAAE,CAElE;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAK,GAAG,KAAK,CAEpE;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,KAAK,CAEvG;AAED,wBAAgB,SAAS,CAAC,QAAQ,GAAE,KAAK,EAAO,EAAE,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,KAAK,CAE5F;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,qBAAqB,CAKhE;AAED,qDAAqD;AACrD,wBAAgB,YAAY,CAAC,IAAI,EAAE,KAAK,GAAG,OAAO,CAEjD;AAED;;;8EAG8E;AAC9E,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAUzE;AAED,uFAAuF;AACvF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,KAAK,GAAG,SAAS,GAAG,OAAO,CAMjE"}
@@ -45,3 +45,9 @@ export function collectFootnoteOrder(blocks) {
45
45
  blocks.forEach(visit);
46
46
  return order;
47
47
  }
48
+ /** A paragraph with nothing in it: no children, or only whitespace and line breaks. */
49
+ export function isEmptyParagraph(node) {
50
+ return (!!node &&
51
+ node.type === 'paragraph' &&
52
+ (node.children ?? []).every((child) => (child.type === 'text' ? String(child.text ?? '').trim() === '' : child.type === 'linebreak')));
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-converters",
3
- "version": "0.1.45",
3
+ "version": "0.1.46-dev.344",
4
4
  "description": "Format transformers (Markdown/HTML/plain-text/Lexical JSON) against the canonical Lexical EditorState, isolated from UI.",
5
5
  "license": "MIT",
6
6
  "repository": {