@fiduswriter/document 0.1.0-alpha.1

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.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +16 -0
  3. package/jest.config.js +23 -0
  4. package/package.json +59 -0
  5. package/schema.json +1 -0
  6. package/scripts/export-schema.js +16 -0
  7. package/src/bibliography/common.js +92 -0
  8. package/src/bibliography/csl_bib.js +139 -0
  9. package/src/citations/citeproc_sys.js +42 -0
  10. package/src/citations/format.js +194 -0
  11. package/src/common/blob.js +10 -0
  12. package/src/common/file.js +25 -0
  13. package/src/common/index.js +12 -0
  14. package/src/common/network.js +79 -0
  15. package/src/common/text.js +44 -0
  16. package/src/editor/e2ee/encryptor.js +228 -0
  17. package/src/exporter/docx/citations.js +177 -0
  18. package/src/exporter/docx/comments.js +165 -0
  19. package/src/exporter/docx/footnotes.js +240 -0
  20. package/src/exporter/docx/images.js +101 -0
  21. package/src/exporter/docx/index.js +185 -0
  22. package/src/exporter/docx/lists.js +260 -0
  23. package/src/exporter/docx/math.js +46 -0
  24. package/src/exporter/docx/metadata.js +289 -0
  25. package/src/exporter/docx/rels.js +193 -0
  26. package/src/exporter/docx/render.js +941 -0
  27. package/src/exporter/docx/richtext.js +1182 -0
  28. package/src/exporter/docx/tables.js +112 -0
  29. package/src/exporter/docx/tools.js +50 -0
  30. package/src/exporter/epub/index.js +142 -0
  31. package/src/exporter/epub/templates.js +140 -0
  32. package/src/exporter/epub/tools.js +96 -0
  33. package/src/exporter/html/citations.js +121 -0
  34. package/src/exporter/html/convert.js +813 -0
  35. package/src/exporter/html/index.js +192 -0
  36. package/src/exporter/html/templates.js +34 -0
  37. package/src/exporter/html/tools.js +50 -0
  38. package/src/exporter/jats/bibliography.js +183 -0
  39. package/src/exporter/jats/citations.js +109 -0
  40. package/src/exporter/jats/convert.js +871 -0
  41. package/src/exporter/jats/index.js +92 -0
  42. package/src/exporter/jats/templates.js +35 -0
  43. package/src/exporter/jats/text.js +72 -0
  44. package/src/exporter/latex/convert.js +934 -0
  45. package/src/exporter/latex/escape_latex.js +21 -0
  46. package/src/exporter/latex/index.js +74 -0
  47. package/src/exporter/latex/readme.js +22 -0
  48. package/src/exporter/native/shrink.js +132 -0
  49. package/src/exporter/odt/citations.js +101 -0
  50. package/src/exporter/odt/footnotes.js +147 -0
  51. package/src/exporter/odt/images.js +115 -0
  52. package/src/exporter/odt/index.js +156 -0
  53. package/src/exporter/odt/math.js +57 -0
  54. package/src/exporter/odt/metadata.js +251 -0
  55. package/src/exporter/odt/render.js +806 -0
  56. package/src/exporter/odt/richtext.js +865 -0
  57. package/src/exporter/odt/styles.js +387 -0
  58. package/src/exporter/odt/track.js +68 -0
  59. package/src/exporter/pandoc/citations.js +98 -0
  60. package/src/exporter/pandoc/convert.js +1017 -0
  61. package/src/exporter/pandoc/index.js +92 -0
  62. package/src/exporter/pandoc/readme.js +8 -0
  63. package/src/exporter/pandoc/tools.js +51 -0
  64. package/src/exporter/print/index.js +177 -0
  65. package/src/exporter/tools/doc_content.js +144 -0
  66. package/src/exporter/tools/file.js +9 -0
  67. package/src/exporter/tools/json.js +73 -0
  68. package/src/exporter/tools/svg.js +29 -0
  69. package/src/exporter/tools/xml.js +531 -0
  70. package/src/exporter/tools/xml_zip.js +95 -0
  71. package/src/exporter/tools/zip.js +90 -0
  72. package/src/exporter/tools/zotero_csl.js +93 -0
  73. package/src/importer/citations.js +129 -0
  74. package/src/importer/docx/citations.js +123 -0
  75. package/src/importer/docx/convert.js +1427 -0
  76. package/src/importer/docx/helpers.js +9 -0
  77. package/src/importer/docx/omml2mathml.js +1448 -0
  78. package/src/importer/docx/parse.js +735 -0
  79. package/src/importer/native/get_images.js +76 -0
  80. package/src/importer/native/update.js +29 -0
  81. package/src/importer/odt/citations.js +87 -0
  82. package/src/importer/odt/convert.js +1855 -0
  83. package/src/importer/pandoc/convert.js +884 -0
  84. package/src/importer/pandoc/helpers.js +84 -0
  85. package/src/importer/zip_analyzer.js +102 -0
  86. package/src/index.js +1 -0
  87. package/src/mathlive/opf_includes.js +24 -0
  88. package/src/schema/common/annotate.js +76 -0
  89. package/src/schema/common/base.js +118 -0
  90. package/src/schema/common/citation.js +62 -0
  91. package/src/schema/common/equation.js +31 -0
  92. package/src/schema/common/figure.js +190 -0
  93. package/src/schema/common/heading.js +43 -0
  94. package/src/schema/common/index.js +40 -0
  95. package/src/schema/common/list.js +95 -0
  96. package/src/schema/common/reference.js +100 -0
  97. package/src/schema/common/table.js +103 -0
  98. package/src/schema/common/track.js +190 -0
  99. package/src/schema/const.js +58 -0
  100. package/src/schema/convert.js +1272 -0
  101. package/src/schema/document/content.js +187 -0
  102. package/src/schema/document/index.js +117 -0
  103. package/src/schema/document/structure.js +452 -0
  104. package/src/schema/export.js +21 -0
  105. package/src/schema/footnotes.js +126 -0
  106. package/src/schema/footnotes_convert.js +31 -0
  107. package/src/schema/i18n.js +595 -0
  108. package/src/schema/index.js +5 -0
  109. package/src/schema/mini_json.js +61 -0
  110. package/src/schema/text.js +22 -0
@@ -0,0 +1,806 @@
1
+ import {escapeText} from "../../common/index.js"
2
+ import {BIBLIOGRAPHY_HEADERS} from "../../schema/i18n.js"
3
+ import {textContent} from "../tools/doc_content.js"
4
+ import {xmlDOM} from "../tools/xml.js"
5
+
6
+ /**
7
+ * Create Zotero bibliography reference mark name for ODT.
8
+ * @returns {string} Reference mark name
9
+ */
10
+ export function createOdtBibliographyMark() {
11
+ return "ZOTERO_BIBL CSL_BIBLIOGRAPHY"
12
+ }
13
+
14
+ export class ODTExporterRender {
15
+ constructor(xml) {
16
+ this.xml = xml
17
+
18
+ this.filePath = "content.xml"
19
+ this.text = false
20
+ }
21
+
22
+ init() {
23
+ return this.xml.getXml(this.filePath).then(xml => {
24
+ this.text = xml.query("office:text")
25
+ return Promise.resolve()
26
+ })
27
+ }
28
+
29
+ parseStructuredTags(block, tag) {
30
+ let blockText = block.textContent
31
+ const tagName = tag.title
32
+
33
+ // Check for BEGIN...END loops (with optional limit)
34
+ const beginStartRegex = new RegExp(
35
+ `\\{BEGIN_${tagName}(?::limit=(\\d+))?\\}`
36
+ )
37
+ const beginStartMatch = blockText.match(beginStartRegex)
38
+
39
+ if (
40
+ beginStartMatch &&
41
+ tag.content &&
42
+ Array.isArray(tag.content) &&
43
+ tag.content.length > 0
44
+ ) {
45
+ const limit = beginStartMatch[1]
46
+ ? parseInt(beginStartMatch[1])
47
+ : null
48
+ const beginStart = beginStartMatch.index
49
+ const beginEnd = beginStart + beginStartMatch[0].length
50
+
51
+ // Find matching {END_tag}
52
+ const endTag = `{END_${tagName}}`
53
+ const endPos = blockText.indexOf(endTag, beginEnd)
54
+ if (endPos === -1) {
55
+ console.warn(`Missing ${endTag} for ${tagName}`)
56
+ return
57
+ }
58
+
59
+ const templateXml = blockText.slice(beginEnd, endPos)
60
+ const replacementXml = this.processLoop(
61
+ templateXml,
62
+ tag.content,
63
+ tagName,
64
+ limit
65
+ )
66
+
67
+ const beforeText = blockText.slice(0, beginStart)
68
+ const afterText = blockText.slice(endPos + endTag.length)
69
+ const fullReplacement = beforeText + replacementXml + afterText
70
+
71
+ block.innerXML = fullReplacement
72
+ return
73
+ }
74
+
75
+ // Check for IF...ELIF...ELSE...ENDIF conditionals
76
+ blockText = this.processConditionals(blockText, {
77
+ tagName,
78
+ count: tag.content ? tag.content.length : 0,
79
+ content: tag.content || []
80
+ })
81
+
82
+ if (blockText !== block.textContent) {
83
+ block.innerXML = blockText
84
+ }
85
+ }
86
+
87
+ processLoop(templateXml, items, tagName, limit = null) {
88
+ const effectiveItems = limit !== null ? items.slice(0, limit) : items
89
+ const results = []
90
+
91
+ effectiveItems.forEach((item, index) => {
92
+ const loopCtx = {
93
+ count: items.length,
94
+ index: index,
95
+ first: index === 0,
96
+ last: index === effectiveItems.length - 1,
97
+ item: item,
98
+ content: [item],
99
+ odd: index % 2 === 1,
100
+ even: index % 2 === 0
101
+ }
102
+
103
+ let itemXml = templateXml
104
+
105
+ // Replace field placeholders
106
+ if (typeof item === "string") {
107
+ itemXml = itemXml.replace(/%tag/g, escapeText(item))
108
+ } else {
109
+ itemXml = itemXml
110
+ .replace(
111
+ /\{?%firstname\}?/g,
112
+ escapeText(item.firstname || "")
113
+ )
114
+ .replace(
115
+ /\{?%lastname\}?/g,
116
+ escapeText(item.lastname || "")
117
+ )
118
+ .replace(
119
+ /\{?%institution\}?/g,
120
+ escapeText(item.institution || "")
121
+ )
122
+ .replace(/\{?%email\}?/g, escapeText(item.email || ""))
123
+ .replace(/\{?%id_type\}?/g, escapeText(item.id_type || ""))
124
+ .replace(
125
+ /\{?%id_value\}?/g,
126
+ escapeText(item.id_value || "")
127
+ )
128
+ }
129
+
130
+ // Handle conditionals inside the loop
131
+ itemXml = this.processConditionals(itemXml, {tagName, ...loopCtx})
132
+
133
+ // Handle special delimiters for ODT
134
+ itemXml = itemXml.replace(/\\n/g, "<text:line-break/>")
135
+ itemXml = itemXml.replace(/\\p/g, "</text:p><text:p>")
136
+
137
+ results.push(itemXml)
138
+ })
139
+
140
+ return results.join("")
141
+ }
142
+
143
+ processConditionals(text, ctx) {
144
+ let result = text
145
+ let changed = true
146
+ while (changed) {
147
+ changed = false
148
+ const ifStart = result.indexOf("{IF(")
149
+ if (ifStart === -1) {
150
+ break
151
+ }
152
+
153
+ let depth = 1
154
+ let pos = ifStart + 4 // skip {IF(
155
+ // Find the closing ) of the IF expression
156
+ while (pos < result.length && result[pos] !== ")") {
157
+ pos++
158
+ }
159
+ if (pos >= result.length) {
160
+ break
161
+ }
162
+ pos++ // skip )
163
+
164
+ // Now scan for matching {ENDIF}
165
+ while (pos < result.length && depth > 0) {
166
+ if (result.substr(pos, 4) === "{IF(") {
167
+ depth++
168
+ pos += 4
169
+ } else if (result.substr(pos, 7) === "{ENDIF}") {
170
+ depth--
171
+ if (depth > 0) {
172
+ pos += 7
173
+ }
174
+ } else {
175
+ pos++
176
+ }
177
+ }
178
+
179
+ if (depth === 0) {
180
+ const exprEnd = result.indexOf(")", ifStart + 4)
181
+ const ifExpr = result.slice(ifStart + 4, exprEnd)
182
+ // Skip the closing } of {IF(...)} if present
183
+ let innerStart = exprEnd + 1
184
+ if (result[innerStart] === "}") {
185
+ innerStart++
186
+ }
187
+ const innerContent = result.slice(innerStart, pos)
188
+
189
+ const conditions = []
190
+ conditions.push({expr: ifExpr, content: ""})
191
+
192
+ const remaining = innerContent
193
+ let lastIndex = 0
194
+
195
+ const elifRegex = /\{ELIF\(([^)]+)\)\}/g
196
+ let elifMatch
197
+ while ((elifMatch = elifRegex.exec(remaining)) !== null) {
198
+ conditions[conditions.length - 1].content = remaining.slice(
199
+ lastIndex,
200
+ elifMatch.index
201
+ )
202
+ conditions.push({expr: elifMatch[1], content: ""})
203
+ lastIndex = elifMatch.index + elifMatch[0].length
204
+ }
205
+
206
+ const elseMatch = remaining.slice(lastIndex).match(/\{ELSE\}/)
207
+ if (elseMatch) {
208
+ conditions[conditions.length - 1].content = remaining.slice(
209
+ lastIndex,
210
+ lastIndex + elseMatch.index
211
+ )
212
+ conditions.push({
213
+ expr: null,
214
+ content: remaining.slice(
215
+ lastIndex + elseMatch.index + elseMatch[0].length
216
+ )
217
+ })
218
+ } else {
219
+ conditions[conditions.length - 1].content =
220
+ remaining.slice(lastIndex)
221
+ }
222
+
223
+ let replacement = ""
224
+ for (const cond of conditions) {
225
+ if (
226
+ cond.expr === null ||
227
+ this.evaluateExpression(cond.expr, ctx)
228
+ ) {
229
+ replacement = cond.content
230
+ break
231
+ }
232
+ }
233
+
234
+ result =
235
+ result.slice(0, ifStart) +
236
+ replacement +
237
+ result.slice(pos + 7)
238
+ changed = true
239
+ }
240
+ }
241
+ return result
242
+ }
243
+
244
+ evaluateExpression(expr, ctx) {
245
+ try {
246
+ // Allow explicit tag name references (e.g., authors.count -> ctx.count)
247
+ if (ctx.tagName) {
248
+ const safeTagName = ctx.tagName.replace(
249
+ /[.*+?^${}()|[\]\\]/g,
250
+ "\\$&"
251
+ )
252
+ expr = expr.replace(
253
+ new RegExp(`\\b${safeTagName}\\b`, "g"),
254
+ "ctx"
255
+ )
256
+ }
257
+
258
+ // Replace ctx.property accesses with literal values
259
+ const evalExpr = expr.replace(
260
+ /ctx\.(\w+)(?:\.(\w+))?(?:\[(\d+)\])?/g,
261
+ (_match, p1, p2, p3) => {
262
+ let val = ctx[p1]
263
+ if (p2 !== undefined && val !== undefined) {
264
+ val = val[p2]
265
+ }
266
+ if (p3 !== undefined && val !== undefined) {
267
+ val = val[parseInt(p3)]
268
+ }
269
+ return JSON.stringify(val)
270
+ }
271
+ )
272
+
273
+ // Remove string literals before character check
274
+ const safeExpr = evalExpr.replace(
275
+ /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g,
276
+ '""'
277
+ )
278
+
279
+ // Check for unknown identifiers
280
+ const bareIdRegex = /\b[a-zA-Z_]\w*\b/g
281
+ const allowed = ["true", "false", "null", "undefined"]
282
+ let m
283
+ while ((m = bareIdRegex.exec(safeExpr)) !== null) {
284
+ if (!allowed.includes(m[0])) {
285
+ console.warn(
286
+ "Unknown identifier in expression:",
287
+ m[0],
288
+ "expression:",
289
+ expr
290
+ )
291
+ return false
292
+ }
293
+ }
294
+
295
+ // Check for unsafe characters
296
+ if (/[^ \t\n\r0-9a-zA-Z_\.\+\-*\/%==<>!&|()\[\]]/.test(safeExpr)) {
297
+ console.warn("Unsafe characters in expression:", expr)
298
+ return false
299
+ }
300
+
301
+ return new Function(`return (${evalExpr})`)()
302
+ } catch (e) {
303
+ console.warn("Error evaluating expression:", expr, e)
304
+ return false
305
+ }
306
+ }
307
+
308
+ // Define the tags that are to be looked for in the document
309
+ getTagData(docContent, pmBib, settings) {
310
+ const tags = docContent.content.map(node => {
311
+ const tag = {}
312
+ switch (node.type) {
313
+ case "title":
314
+ tag.title = "title"
315
+ tag.content = textContent(node)
316
+ break
317
+ case "heading_part":
318
+ tag.title = node.attrs.id
319
+ tag.content = textContent(node)
320
+ break
321
+ case "table_part":
322
+ case "richtext_part":
323
+ tag.title = `@${node.attrs.id}`
324
+ tag.content = node.content
325
+ break
326
+ case "contributors_part":
327
+ tag.title = node.attrs.id
328
+ // Return array of structured objects for format with delimiter support
329
+ tag.content = node.content
330
+ ? node.content.map(node => {
331
+ const c = node.attrs
332
+ return {
333
+ firstname: c.firstname || "",
334
+ lastname: c.lastname || "",
335
+ institution: c.institution || "",
336
+ email: c.email || "",
337
+ id_type: c.id_type || "",
338
+ id_value: c.id_value || ""
339
+ }
340
+ })
341
+ : []
342
+ break
343
+ case "tags_part":
344
+ tag.title = node.attrs.id
345
+ // Return array of tag strings for format with delimiter support
346
+ tag.content = node.content
347
+ ? node.content.map(node => node.attrs.tag)
348
+ : []
349
+ break
350
+ }
351
+ return tag
352
+ })
353
+ const bibliographyHeader =
354
+ settings.bibliography_header[settings.language] ||
355
+ BIBLIOGRAPHY_HEADERS[settings.language]
356
+ tags.push({
357
+ title: "@bibliography", // The '@' triggers handling as block
358
+ content: pmBib
359
+ ? [
360
+ {
361
+ type: "bibliography_heading",
362
+ content: [{type: "text", text: bibliographyHeader}]
363
+ },
364
+ pmBib
365
+ ]
366
+ : [{type: "paragraph", content: [{type: "text", text: " "}]}]
367
+ })
368
+ tags.push({
369
+ title: "@copyright", // The '@' triggers handling as block
370
+ content:
371
+ settings.copyright && settings.copyright.holder
372
+ ? [
373
+ {
374
+ type: "paragraph",
375
+ content: [
376
+ {
377
+ type: "text",
378
+ text: `© ${settings.copyright.year ? settings.copyright.year : new Date().getFullYear()} ${settings.copyright.holder}`
379
+ }
380
+ ]
381
+ }
382
+ ]
383
+ : [
384
+ {
385
+ type: "paragraph",
386
+ content: [{type: "text", text: " "}]
387
+ }
388
+ ]
389
+ })
390
+ tags.push({
391
+ title: "@licenses", // The '@' triggers handling as block
392
+ content:
393
+ settings.copyright && settings.copyright.licenses.length
394
+ ? settings.copyright.licenses.map(license => ({
395
+ type: "paragraph",
396
+ content: [
397
+ {
398
+ type: "text",
399
+ marks: [
400
+ {
401
+ type: "link",
402
+ attrs: {
403
+ href: license.url,
404
+ title: license.url
405
+ }
406
+ }
407
+ ],
408
+ text: license.title
409
+ },
410
+ {
411
+ type: "text",
412
+ text: license.start
413
+ ? ` (${license.start})`
414
+ : ""
415
+ }
416
+ ]
417
+ }))
418
+ : [
419
+ {
420
+ type: "paragraph",
421
+ content: [{type: "text", text: " "}]
422
+ }
423
+ ]
424
+ })
425
+ return tags
426
+ }
427
+
428
+ processMultiBlockStructuredTags(blocks, tags) {
429
+ const tagMap = {}
430
+ tags.forEach(tag => {
431
+ if (tag.title) {
432
+ tagMap[tag.title] = tag
433
+ }
434
+ })
435
+
436
+ // Process from end to start to avoid index shifting issues
437
+ for (let i = blocks.length - 1; i >= 0; i--) {
438
+ const block = blocks[i]
439
+ const text = block.textContent
440
+
441
+ // Check for multi-block BEGIN...END loops
442
+ for (const tag of tags) {
443
+ if (!tag.title || !tag.content || !Array.isArray(tag.content)) {
444
+ continue
445
+ }
446
+ const tagName = tag.title
447
+ const beginRegex = new RegExp(
448
+ `\\{BEGIN_${tagName}(?::limit=(\\d+))?\\}`
449
+ )
450
+ const beginMatch = text.match(beginRegex)
451
+ if (!beginMatch) {
452
+ continue
453
+ }
454
+
455
+ // Find matching END in a later block
456
+ let endIndex = -1
457
+ for (let j = i + 1; j < blocks.length; j++) {
458
+ if (blocks[j].textContent.includes(`{END_${tagName}}`)) {
459
+ endIndex = j
460
+ break
461
+ }
462
+ }
463
+
464
+ if (endIndex === -1 || endIndex === i) {
465
+ continue
466
+ }
467
+
468
+ // Found multi-block loop - process it
469
+ const limit = beginMatch[1] ? parseInt(beginMatch[1]) : null
470
+ this._replaceMultiBlockLoop(blocks, i, endIndex, tag, limit)
471
+ i = Math.min(i, blocks.length - 1)
472
+ break
473
+ }
474
+ }
475
+
476
+ // Process multi-block conditionals from end to start
477
+ for (let i = blocks.length - 1; i >= 0; i--) {
478
+ const block = blocks[i]
479
+ const text = block.textContent
480
+ const ifMatch = text.match(/\{IF\(([^)]+)\)\}/)
481
+ if (!ifMatch) {
482
+ continue
483
+ }
484
+
485
+ // Find matching ENDIF in a later block
486
+ let endIndex = -1
487
+ for (let j = i + 1; j < blocks.length; j++) {
488
+ if (/\{ENDIF\}/.test(blocks[j].textContent)) {
489
+ endIndex = j
490
+ break
491
+ }
492
+ }
493
+
494
+ if (endIndex === -1 || endIndex === i) {
495
+ continue
496
+ }
497
+
498
+ this._replaceMultiBlockConditional(
499
+ blocks,
500
+ i,
501
+ endIndex,
502
+ ifMatch[1],
503
+ tagMap
504
+ )
505
+ i = Math.min(i, blocks.length - 1)
506
+ }
507
+ }
508
+
509
+ _replaceMultiBlockLoop(blocks, beginIndex, endIndex, tag, limit) {
510
+ const tagName = tag.title
511
+ const beginBlock = blocks[beginIndex]
512
+
513
+ // Concatenate all blocks from begin to end
514
+ let combinedXml = ""
515
+ for (let i = beginIndex; i <= endIndex; i++) {
516
+ combinedXml += blocks[i].toString()
517
+ }
518
+
519
+ // Find the BEGIN and END tags in the combined XML
520
+ const beginRegex = new RegExp(`\\{BEGIN_${tagName}(?::limit=\\d+)?\\}`)
521
+ const beginMatch = combinedXml.match(beginRegex)
522
+ const endTag = `{END_${tagName}}`
523
+ const endPos = combinedXml.indexOf(endTag)
524
+
525
+ if (!beginMatch || endPos === -1) {
526
+ return
527
+ }
528
+
529
+ const beforeXml = combinedXml.slice(0, beginMatch.index)
530
+ const templateXml = combinedXml.slice(
531
+ beginMatch.index + beginMatch[0].length,
532
+ endPos
533
+ )
534
+ const afterXml = combinedXml.slice(endPos + endTag.length)
535
+
536
+ // Decode &gt; so expressions like >= work in nested conditionals
537
+ const decodedTemplateXml = templateXml.replace(/&gt;/g, ">")
538
+ const replacementXml = this.processLoop(
539
+ decodedTemplateXml,
540
+ tag.content,
541
+ tagName,
542
+ limit
543
+ )
544
+ const fullReplacement = beforeXml + replacementXml + afterXml
545
+
546
+ // Parse replacement
547
+ const parent = beginBlock.parentElement
548
+ const dom = xmlDOM(`<root>${fullReplacement}</root>`)
549
+ const root = dom.query("root")
550
+ const newBlocks = root.children.filter(
551
+ child => child.tagName === "text:p" || child.tagName === "text:h"
552
+ )
553
+
554
+ // Insert new blocks before begin block
555
+ for (let i = newBlocks.length - 1; i >= 0; i--) {
556
+ parent.insertBefore(newBlocks[i], beginBlock)
557
+ }
558
+
559
+ // Remove old blocks
560
+ for (let i = endIndex; i >= beginIndex; i--) {
561
+ parent.removeChild(blocks[i])
562
+ }
563
+
564
+ // Update blocks array
565
+ blocks.splice(beginIndex, endIndex - beginIndex + 1, ...newBlocks)
566
+ }
567
+
568
+ _replaceMultiBlockConditional(blocks, ifIndex, endIndex, expr, tagMap) {
569
+ const ifBlock = blocks[ifIndex]
570
+
571
+ // Concatenate all blocks from if to endif
572
+ let combinedXml = ""
573
+ for (let i = ifIndex; i <= endIndex; i++) {
574
+ combinedXml += blocks[i].toString()
575
+ }
576
+
577
+ // Determine which tag the expression references
578
+ let ctx = {count: 0, content: []}
579
+ for (const tagName in tagMap) {
580
+ const safeTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
581
+ if (new RegExp(`\\b${safeTagName}\\b`).test(expr)) {
582
+ const tag = tagMap[tagName]
583
+ ctx = {
584
+ tagName: tag.title,
585
+ count: tag.content ? tag.content.length : 0,
586
+ content: tag.content || []
587
+ }
588
+ break
589
+ }
590
+ }
591
+
592
+ // Decode &gt; so expressions like >= work in conditionals
593
+ const decodedXml = combinedXml.replace(/&gt;/g, ">")
594
+ // Process conditionals on the combined XML
595
+ const processedXml = this.processConditionals(decodedXml, ctx)
596
+
597
+ if (processedXml === combinedXml) {
598
+ return
599
+ }
600
+
601
+ // Parse and replace
602
+ const parent = ifBlock.parentElement
603
+ const dom = xmlDOM(`<root>${processedXml}</root>`)
604
+ const root = dom.query("root")
605
+ const newBlocks = root.children.filter(
606
+ child => child.tagName === "text:p" || child.tagName === "text:h"
607
+ )
608
+
609
+ for (let i = newBlocks.length - 1; i >= 0; i--) {
610
+ parent.insertBefore(newBlocks[i], ifBlock)
611
+ }
612
+
613
+ for (let i = endIndex; i >= ifIndex; i--) {
614
+ parent.removeChild(blocks[i])
615
+ }
616
+
617
+ blocks.splice(ifIndex, endIndex - ifIndex + 1, ...newBlocks)
618
+ }
619
+
620
+ // go through content.xml looking for tags and replace them with the given
621
+ // replacements.
622
+ render(docContent, pmBib, settings, richtext, citations) {
623
+ const tags = this.getTagData(docContent, pmBib, settings)
624
+ const textBlocks = this.text.queryAll(["text:p", "text:h"])
625
+
626
+ // Process multi-block structured tags first (BEGIN...END across paragraphs)
627
+ this.processMultiBlockStructuredTags(textBlocks, tags)
628
+
629
+ textBlocks.forEach(block => {
630
+ if (block.parentElement.nodeName === "text:deletion") {
631
+ // Inside of tracked changes deletion, don't do anything
632
+ return
633
+ }
634
+ const text = block.textContent
635
+ tags.forEach(tag => {
636
+ const tagString = tag.title
637
+ const hasInlineTag =
638
+ text.includes(`{${tagString}}`) ||
639
+ text.includes(`{${tagString}:format=`)
640
+ const hasBeginTag = text.includes(`{BEGIN_${tagString}}`)
641
+ const hasIfTag =
642
+ text.includes(`{IF(${tagString}.`) ||
643
+ text.includes(`{IF(ctx.`)
644
+ if (hasInlineTag || hasBeginTag || hasIfTag) {
645
+ tag.block = block
646
+ if (hasInlineTag && tag.title[0] === "@") {
647
+ this.blockRender(tag, richtext, citations)
648
+ } else if (hasInlineTag && tag.title[0] !== "@") {
649
+ this.inlineRender(tag)
650
+ }
651
+ }
652
+ })
653
+
654
+ // Parse structured tags (BEGIN...END and IF...ENDIF)
655
+ tags.forEach(tag => {
656
+ if (tag.block) {
657
+ this.parseStructuredTags(tag.block, tag)
658
+ }
659
+ })
660
+ })
661
+ }
662
+
663
+ // Render Tags that only exchange inline content
664
+ inlineRender(tag) {
665
+ const blockText = tag.block.textContent
666
+ const tagString = tag.title
667
+
668
+ if (!blockText.includes(`{${tag.title}`)) {
669
+ // No inline tag present - structured tags only
670
+ return
671
+ }
672
+
673
+ // Check for format string with delimiter: {tag:format=%firstname|; }
674
+ const formatRegex = new RegExp(
675
+ `\\{${tagString}:format=([^|]+)\\|?([^}]*)?\\}`
676
+ )
677
+ const formatMatch = blockText.match(formatRegex)
678
+
679
+ let fullText = ""
680
+
681
+ if (formatMatch && tag.content && Array.isArray(tag.content)) {
682
+ // Find format string and delimiter
683
+ const [, format, delimiter = "; "] = formatMatch
684
+
685
+ // Process each item with the format string
686
+ const formattedItems = tag.content
687
+ .map(item => {
688
+ if (typeof item === "string") {
689
+ // For tags (simple strings)
690
+ return format.replace(/%tag/g, item)
691
+ } else {
692
+ // For contributors (objects)
693
+ return format
694
+ .replace(/%firstname/g, item.firstname || "")
695
+ .replace(/%lastname/g, item.lastname || "")
696
+ .replace(/%institution/g, item.institution || "")
697
+ .replace(/%email/g, item.email || "")
698
+ .replace(/%id_type/g, item.id_type || "")
699
+ .replace(/%id_value/g, item.id_value || "")
700
+ }
701
+ })
702
+ .filter(s => s.trim() !== "")
703
+
704
+ // Handle special delimiters for ODT
705
+ let delimiterXml = delimiter
706
+ delimiterXml = delimiterXml.replace(/\\n/g, "<text:line-break/>")
707
+ delimiterXml = delimiterXml.replace(
708
+ /\\p/g,
709
+ "<text:line-break/><text:line-break/>"
710
+ )
711
+
712
+ const replacement = formattedItems.join(delimiterXml)
713
+ fullText = blockText.replace(formatRegex, replacement)
714
+ } else {
715
+ // Fall back to simple string replacement (backward compatible)
716
+ let contentStr = tag.content || ""
717
+ if (Array.isArray(contentStr)) {
718
+ if (contentStr.length === 0) {
719
+ contentStr = ""
720
+ } else if (typeof contentStr[0] === "string") {
721
+ contentStr = contentStr.join(", ")
722
+ } else {
723
+ // Contributors - backward compatible formatting
724
+ contentStr = contentStr
725
+ .map(item => {
726
+ const nameParts = []
727
+ let affiliation = false
728
+ if (item.firstname) {
729
+ nameParts.push(item.firstname)
730
+ }
731
+ if (item.lastname) {
732
+ nameParts.push(item.lastname)
733
+ }
734
+ if (item.institution) {
735
+ if (nameParts.length) {
736
+ affiliation = item.institution
737
+ } else {
738
+ nameParts.push(item.institution)
739
+ }
740
+ }
741
+ const parts = [nameParts.join(" ")]
742
+ if (affiliation) {
743
+ parts.push(affiliation)
744
+ }
745
+ if (item.email) {
746
+ parts.push(item.email)
747
+ }
748
+ if (item.id_type && item.id_value) {
749
+ parts.push(`${item.id_type}: ${item.id_value}`)
750
+ }
751
+ return parts.join(", ")
752
+ })
753
+ .join("; ")
754
+ }
755
+ }
756
+ const texts = blockText.split(`{${tagString}}`)
757
+ fullText = texts[0] + contentStr + texts[1]
758
+ }
759
+
760
+ // Escape text but restore ODT XML line break tags
761
+ fullText = escapeText(fullText).replace(
762
+ /&lt;text:line-break\/&gt;/g,
763
+ "<text:line-break/>"
764
+ )
765
+
766
+ tag.block.innerXML = fullText.replace(/^\s+|\s+$/g, match =>
767
+ "<text:s/>".repeat(match.length)
768
+ )
769
+ }
770
+
771
+ // Render tags that exchange text blocks
772
+ blockRender(tag, richtext, citations) {
773
+ const section = tag.block.hasAttribute("text:style-name")
774
+ ? tag.block.getAttribute("text:style-name")
775
+ : "Text_20_body"
776
+ const outXML = tag.content
777
+ ? tag.content
778
+ .map((content, contentIndex) =>
779
+ richtext.run(
780
+ content,
781
+ {
782
+ citationType: citations.citFm.citationType,
783
+ section,
784
+ tag: tag.title.slice(1)
785
+ },
786
+ tag,
787
+ contentIndex
788
+ )
789
+ )
790
+ .join("")
791
+ : ""
792
+
793
+ if (!outXML.length) {
794
+ // If there is no content, we need to put in a space to prevent the
795
+ // tag from being removed by LibreOffice.
796
+ tag.block.innerXML = "<text:s/>"
797
+ return
798
+ }
799
+ const parentElement = tag.block.parentElement
800
+ const dom = xmlDOM(outXML)
801
+ const domPars = dom.node["#document"]?.slice() || [dom]
802
+ domPars.forEach(node => parentElement.insertBefore(node, tag.block))
803
+
804
+ parentElement.removeChild(tag.block)
805
+ }
806
+ }