@cyanheads/pubmed-mcp-server 2.10.19 → 2.10.21

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/AGENTS.md +8 -5
  2. package/CLAUDE.md +8 -5
  3. package/Dockerfile +12 -3
  4. package/README.md +12 -11
  5. package/changelog/2.10.x/2.10.20.md +63 -0
  6. package/changelog/2.10.x/2.10.21.md +42 -0
  7. package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
  8. package/dist/mcp-server/tools/definitions/_text.js +10 -5
  9. package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +8 -0
  11. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +64 -14
  13. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +44 -9
  15. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +815 -563
  17. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +5 -5
  19. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/find-related.tool.js +12 -8
  21. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
  23. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/format-citations.tool.js +53 -7
  25. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +11 -7
  27. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +72 -34
  29. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +2 -1
  31. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +11 -3
  33. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +18 -0
  35. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +96 -13
  37. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -3
  39. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +7 -5
  41. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +6 -4
  43. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/search-articles.tool.js +33 -11
  45. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  46. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +2 -1
  47. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  48. package/dist/mcp-server/tools/definitions/spell-check.tool.js +5 -1
  49. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  50. package/dist/services/error-contracts.d.ts +1 -1
  51. package/dist/services/error-contracts.d.ts.map +1 -1
  52. package/dist/services/error-contracts.js +1 -1
  53. package/dist/services/error-contracts.js.map +1 -1
  54. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  55. package/dist/services/europe-pmc/api-client.js +4 -3
  56. package/dist/services/europe-pmc/api-client.js.map +1 -1
  57. package/dist/services/fetch-redaction.d.ts +23 -0
  58. package/dist/services/fetch-redaction.d.ts.map +1 -0
  59. package/dist/services/fetch-redaction.js +62 -0
  60. package/dist/services/fetch-redaction.js.map +1 -0
  61. package/dist/services/ncbi/api-client.d.ts +3 -0
  62. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  63. package/dist/services/ncbi/api-client.js +21 -8
  64. package/dist/services/ncbi/api-client.js.map +1 -1
  65. package/dist/services/ncbi/formatting/citation-formatter.d.ts +42 -2
  66. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  67. package/dist/services/ncbi/formatting/citation-formatter.js +370 -91
  68. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  69. package/dist/services/ncbi/ncbi-service.d.ts +16 -1
  70. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  71. package/dist/services/ncbi/ncbi-service.js +117 -71
  72. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  73. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  74. package/dist/services/ncbi/parsing/article-parser.js +116 -61
  75. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  76. package/dist/services/ncbi/parsing/esummary-parser.d.ts +9 -0
  77. package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
  78. package/dist/services/ncbi/parsing/esummary-parser.js +26 -2
  79. package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
  80. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +8 -7
  81. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  82. package/dist/services/ncbi/parsing/pmc-article-parser.js +633 -185
  83. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  84. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +102 -5
  85. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  86. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +389 -28
  87. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  88. package/dist/services/ncbi/parsing/text-helpers.d.ts +8 -6
  89. package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
  90. package/dist/services/ncbi/parsing/text-helpers.js +95 -19
  91. package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
  92. package/dist/services/ncbi/response-handler.d.ts +20 -12
  93. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  94. package/dist/services/ncbi/response-handler.js +115 -19
  95. package/dist/services/ncbi/response-handler.js.map +1 -1
  96. package/dist/services/ncbi/types.d.ts +44 -4
  97. package/dist/services/ncbi/types.d.ts.map +1 -1
  98. package/dist/services/openalex/api-client.d.ts.map +1 -1
  99. package/dist/services/openalex/api-client.js +4 -3
  100. package/dist/services/openalex/api-client.js.map +1 -1
  101. package/dist/services/openalex/openalex-service.d.ts +10 -6
  102. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  103. package/dist/services/openalex/openalex-service.js +13 -9
  104. package/dist/services/openalex/openalex-service.js.map +1 -1
  105. package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
  106. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  107. package/dist/services/unpaywall/unpaywall-service.js +18 -8
  108. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  109. package/package.json +14 -12
  110. package/server.json +10 -24
@@ -7,7 +7,7 @@
7
7
  * inline children and drops body sections for markup-heavy articles.
8
8
  * @module src/services/ncbi/parsing/pmc-article-parser
9
9
  */
10
- import { attrOf, childrenOf, findAll, findAllDescendants, findOne, isTextNode, rawTextContent, selectAlternative, tagNameOf, textContent, textContentExcluding, textOf, } from './pmc-xml-helpers.js';
10
+ import { attrOf, childrenOf, findAll, findAllDescendants, findOne, IDENTIFIER_TAGS, isMathTag, isTextNode, linearMath, lineTextContent, rawTextContent, selectAlternative, TextAssembler, tagNameOf, textContent, textContentExcluding, textOf, } from './pmc-xml-helpers.js';
11
11
  /**
12
12
  * Block content extracted into a field of its own and therefore left out of the
13
13
  * prose a section contributes. Each one nested inside a `<p>` would otherwise be
@@ -116,14 +116,22 @@ function extractArticleId(articleMeta, pubIdType) {
116
116
  return;
117
117
  }
118
118
  // ─── Authors & Affiliations ─────────────────────────────────────────────────
119
+ /**
120
+ * True for a `<contrib>` that is an author: `contrib-type="author"` or no type
121
+ * at all. Editors, reviewers and every other typed contributor are not, and
122
+ * neither they nor their affiliations reach the output.
123
+ */
124
+ function isAuthorContrib(contrib) {
125
+ const contribType = attrOf(contrib, 'contrib-type');
126
+ return !contribType || contribType === 'author';
127
+ }
119
128
  /** Extract authors from a single `<contrib-group>` node. Non-author contributors are skipped. */
120
129
  export function extractJatsAuthors(contribGroup) {
121
130
  if (!contribGroup)
122
131
  return [];
123
132
  const authors = [];
124
133
  for (const contrib of findAll(contribGroup, 'contrib')) {
125
- const contribType = attrOf(contrib, 'contrib-type');
126
- if (contribType && contribType !== 'author')
134
+ if (!isAuthorContrib(contrib))
127
135
  continue;
128
136
  const collab = findOne(contrib, 'collab');
129
137
  if (collab) {
@@ -144,16 +152,42 @@ export function extractJatsAuthors(contribGroup) {
144
152
  }
145
153
  return authors;
146
154
  }
155
+ /**
156
+ * Every `<aff>` in the front matter, in document order: directly under
157
+ * `<article-meta>`, inside a `<contrib-group>`, or inside one `<contrib>` of it.
158
+ * Most deposits put them under `<contrib-group>` — 131 articles of a
159
+ * 187-article draw carried theirs only there — and reading `<article-meta>`
160
+ * alone returned none for those. Some deposits repeat one affiliation as a separate
161
+ * `<aff>` in every `<contrib>` it applies to; the text is reported once. (#196)
162
+ *
163
+ * An affiliation belongs to the authors only: an `<aff>` inside a non-author
164
+ * `<contrib>` (PMC10666927's reviewing editors), and one directly under a
165
+ * `<contrib-group>` that holds no author, are left out, as those contributors are
166
+ * left out of `authors`. A group mixing authors and others keeps its direct
167
+ * `<aff>`s.
168
+ */
147
169
  function extractAffiliations(articleMeta) {
148
170
  if (!articleMeta)
149
171
  return [];
150
- const result = [];
151
- for (const aff of findAll(articleMeta, 'aff')) {
152
- const text = textContent(aff);
153
- if (text)
154
- result.push(text);
172
+ const affs = [];
173
+ for (const child of childrenOf(articleMeta)) {
174
+ const tag = tagNameOf(child);
175
+ if (tag === 'aff')
176
+ affs.push(child);
177
+ if (tag !== 'contrib-group')
178
+ continue;
179
+ const contribs = findAll(child, 'contrib');
180
+ const holdsAuthor = contribs.length === 0 || contribs.some(isAuthorContrib);
181
+ for (const member of childrenOf(child)) {
182
+ const memberTag = tagNameOf(member);
183
+ if (memberTag === 'aff' && holdsAuthor)
184
+ affs.push(member);
185
+ else if (memberTag === 'contrib' && isAuthorContrib(member)) {
186
+ affs.push(...findAll(member, 'aff'));
187
+ }
188
+ }
155
189
  }
156
- return result;
190
+ return [...new Set(affs.map(textContent).filter(Boolean))];
157
191
  }
158
192
  // ─── Journal & Publication Date ─────────────────────────────────────────────
159
193
  function extractJournal(journalMeta, articleMeta) {
@@ -210,6 +244,8 @@ function extractPubDate(articleMeta) {
210
244
  };
211
245
  }
212
246
  // ─── Abstract & Keywords ────────────────────────────────────────────────────
247
+ /** Closing punctuation a deposited section title can already end in. */
248
+ const TERMINAL_PUNCTUATION_RE = /[:.?!]$/;
213
249
  /**
214
250
  * Read the article's own abstract.
215
251
  *
@@ -226,6 +262,20 @@ function extractPubDate(articleMeta) {
226
262
  * abstract leaves its marker and its caption reaches `assets[]` alone —
227
263
  * {@link extractPmcAssets} already walks `<front>` — instead of concatenating
228
264
  * into the prose, and a `<list>` renders as list lines rather than a token run.
265
+ * A section that opens with a list starts it on the line below its
266
+ * `Title:` heading. (#202)
267
+ *
268
+ * A structured abstract is read child by child in source order: each `<sec>`
269
+ * as `Title: text`, and each run of content outside any `<sec>` as an untitled
270
+ * section reads — its statements space-joined, a blank line between it and the
271
+ * sections around it. A lead paragraph before the first section therefore
272
+ * opens the abstract as a paragraph of its own above the first `Title:` line.
273
+ * Springer deposits the whole abstract that way, ahead of a "Supplementary
274
+ * Information" section (PMC11281965), and reading the `<sec>`s alone returned
275
+ * that notice as the abstract. (#204)
276
+ *
277
+ * A section title deposited with its own closing punctuation (`Background:`,
278
+ * `Study Design.`, `What is known?`) keeps it in place of the added colon. (#205)
229
279
  */
230
280
  function extractAbstract(articleMeta) {
231
281
  if (!articleMeta)
@@ -234,34 +284,82 @@ function extractAbstract(articleMeta) {
234
284
  const abstractNode = abstracts.find((node) => !attrOf(node, 'abstract-type')) ?? abstracts[0];
235
285
  if (!abstractNode)
236
286
  return;
237
- const sections = findAll(abstractNode, 'sec');
238
- if (sections.length > 0) {
239
- const parts = [];
240
- for (const sec of sections) {
241
- const title = textContent(findOne(sec, 'title'));
242
- const text = abstractProse(sec);
243
- if (title && text)
244
- parts.push(`${title}: ${text}`);
245
- else if (text)
246
- parts.push(text);
287
+ const children = childrenOf(abstractNode);
288
+ if (!findOne(abstractNode, 'sec'))
289
+ return abstractProse(children).text || undefined;
290
+ const parts = [];
291
+ let outside = [];
292
+ const addOutside = () => {
293
+ const { text } = abstractProse(outside);
294
+ if (text)
295
+ parts.push(text);
296
+ outside = [];
297
+ };
298
+ for (const child of children) {
299
+ if (tagNameOf(child) !== 'sec') {
300
+ outside.push(child);
301
+ continue;
247
302
  }
248
- return parts.join('\n\n').trim() || undefined;
303
+ addOutside();
304
+ const title = textContent(findOne(child, 'title'));
305
+ const { ownLineFirst, text } = abstractProse(childrenOf(child));
306
+ const heading = TERMINAL_PUNCTUATION_RE.test(title) ? title : `${title}:`;
307
+ if (title && text)
308
+ parts.push(`${heading}${ownLineFirst ? '\n' : ' '}${text}`);
309
+ else if (text)
310
+ parts.push(text);
249
311
  }
250
- return abstractProse(abstractNode) || undefined;
312
+ addOutside();
313
+ return parts.join('\n\n').trim() || undefined;
251
314
  }
252
315
  /**
253
- * The prose of an `<abstract>` or one of its `<sec>`s: every child but the
254
- * heading the caller reports separately, space-joined. The single space is the
255
- * spacing the paragraph-only read this replaces already produced, so a record
256
- * depositing one untyped abstract of plain `<p>`s comes back byte-identical.
316
+ * The prose of a run of abstract content — an `<abstract>`'s or a `<sec>`'s
317
+ * children, or what an abstract holds outside its sections — leaving out the
318
+ * `<title>` and `<label>` the caller reports separately or not at all.
319
+ * Statements are space-joined — the spacing the paragraph-only read this
320
+ * replaces already produced, so a record depositing one untyped abstract of
321
+ * plain `<p>`s comes back byte-identical.
322
+ *
323
+ * A list reads on lines of its own, as it does in a body section: a blank line
324
+ * rather than a space separates it from the statement before and after it, so
325
+ * neither its first item runs onto the text before it nor its last item onto
326
+ * the heading or sentence after it (PMC8533648). A delimited box does the same,
327
+ * so its `[Box]` and `[End of box]` lines stand alone. `ownLineFirst` reports
328
+ * either on the first line, which the caller starts below the section's
329
+ * heading (PMC10869376). (#202, #210)
330
+ *
331
+ * A run of {@link BULLET_STATEMENT} paragraphs — a "Key points" section a
332
+ * deposit writes as `<p>• …</p>` rather than as a `<list>` — lays out the same
333
+ * way: one paragraph per line with its bullet kept, set apart from the
334
+ * statements around it by a blank line, and the first below the heading
335
+ * (PMC10799778). (#218)
257
336
  */
258
- function abstractProse(node) {
259
- const prose = childrenOf(node).filter((child) => {
260
- const tag = tagNameOf(child);
337
+ function abstractProse(nodes) {
338
+ const prose = nodes.filter((node) => {
339
+ const tag = tagNameOf(node);
261
340
  return tag !== 'title' && tag !== 'label';
262
341
  });
263
- return flowBlocks(prose, STATEMENT_BLOCK_TAGS).join(' ');
342
+ const blocks = flowParts(prose, STATEMENT_BLOCK_TAGS);
343
+ const bullet = blocks.map((block) => !block.ownLineFirst && BULLET_STATEMENT.test(block.text));
344
+ let text = '';
345
+ for (const [i, block] of blocks.entries()) {
346
+ const previous = blocks[i - 1];
347
+ if (previous) {
348
+ const blankLine = previous.ownLineLast || block.ownLineFirst || bullet[i - 1] !== bullet[i];
349
+ text += blankLine ? '\n\n' : bullet[i] ? '\n' : ' ';
350
+ }
351
+ text += block.text;
352
+ }
353
+ return { ownLineFirst: (blocks[0]?.ownLineFirst ?? false) || (bullet[0] ?? false), text };
264
354
  }
355
+ /**
356
+ * A statement deposited as a bullet point: its text opens with a bullet
357
+ * character — `•` U+2022, `‣` U+2023, `⁃` U+2043 or `◦` U+25E6 — and carries
358
+ * text after it. Characters that open a paragraph with another meaning stay
359
+ * out: `·` and `∙` (multiplication), `*` and daggers (footnote marks), hyphens
360
+ * and dashes (minus signs, ranges), `■` (an end-of-proof mark). (#218)
361
+ */
362
+ const BULLET_STATEMENT = /^[•‣⁃◦]\s*\S/;
265
363
  function extractKeywords(articleMeta) {
266
364
  if (!articleMeta)
267
365
  return [];
@@ -278,12 +376,18 @@ function extractKeywords(articleMeta) {
278
376
  // ─── Block Rendering ────────────────────────────────────────────────────────
279
377
  /** Indent one nesting level of a `<list>`/`<def-list>` nested in a `<list-item>`. */
280
378
  const NESTED_LIST_INDENT = ' ';
379
+ /** A {@link FlowBlock} whose edges both stand on their own lines (`ownLines`), or neither does. */
380
+ const flowBlock = (text, ownLines = false) => ({
381
+ text,
382
+ ownLineFirst: ownLines,
383
+ ownLineLast: ownLines,
384
+ });
281
385
  /** Close the prose run in progress, discarding it when it holds only whitespace. */
282
386
  function flushRun(flow) {
283
- const text = flow.run.replace(/\s+/g, ' ').trim();
387
+ const text = flow.run.toString().replace(/\s+/g, ' ').trim();
284
388
  if (text)
285
- flow.out.push(text);
286
- flow.run = '';
389
+ flow.out.push(flowBlock(text));
390
+ flow.run = new TextAssembler();
287
391
  }
288
392
  /**
289
393
  * Walk a mixed-content child list, accumulating inline text into a prose run and
@@ -299,20 +403,30 @@ function flushRun(flow) {
299
403
  * prose, {@link STATEMENT_BLOCK_TAGS} for a container whose `<title>` and `<p>`
300
404
  * children are separate statements rather than one continuous sentence.
301
405
  *
302
- * `<tex-math>` and `<alternatives>` are the two elements whose text is not the
303
- * concatenation of their subtree, so this walk defers to the shared rule rather
304
- * than reading their children one at a time: a `<tex-math>` contributes its
305
- * LaTeX document body via `rawTextContent`, and an `<alternatives>` re-enters
306
- * this walk with its single chosen child, which keeps a `<disp-formula>` found
307
- * there at block position. (#135)
406
+ * `<tex-math>`, MathML `<math>`, `<alternatives>` and the {@link IDENTIFIER_TAGS}
407
+ * are the elements whose text is not the concatenation of their subtree, so
408
+ * this walk defers to the shared rule rather than reading their children one at
409
+ * a time: a `<tex-math>` contributes its LaTeX document body via
410
+ * `rawTextContent`, a `<math>` its TeX-style linear form via `linearMath`
411
+ * wherever it sits — an inline formula or directly in a `<p>` — an
412
+ * `<alternatives>` re-enters this walk with its single chosen child, which keeps
413
+ * a `<disp-formula>` found there at block position, and an `<institution-id>`
414
+ * in a funding statement contributes nothing. (#135, #207, #208)
415
+ *
416
+ * The run is a {@link TextAssembler}, so prose follows the separator rules every
417
+ * other text field does: a `<break/>`, an inline `<fn>` or a `<label>` beside its
418
+ * text reads as one space rather than fusing the words on either side, and a run
419
+ * of `<xref>` markers with nothing between them reads `[1,2,3,4]`. (#185, #197)
308
420
  */
309
421
  function walkFlow(nodes, flow, blockTags) {
310
422
  for (const node of nodes) {
311
423
  if (isTextNode(node)) {
312
- flow.run += textOf(node);
424
+ flow.run.text(textOf(node));
313
425
  continue;
314
426
  }
315
427
  const tag = tagNameOf(node) ?? '';
428
+ if (IDENTIFIER_TAGS.has(tag))
429
+ continue;
316
430
  if (tag === 'alternatives') {
317
431
  const chosen = selectAlternative(node);
318
432
  if (chosen)
@@ -320,26 +434,36 @@ function walkFlow(nodes, flow, blockTags) {
320
434
  continue;
321
435
  }
322
436
  if (tag === 'tex-math') {
323
- flow.run += rawTextContent(node);
437
+ flow.run.text(rawTextContent(node));
438
+ continue;
439
+ }
440
+ if (isMathTag(tag)) {
441
+ flow.run.text(linearMath(node));
324
442
  continue;
325
443
  }
326
444
  if (blockTags.has(tag)) {
327
445
  flushRun(flow);
328
- const rendered = renderBlock(node);
329
- if (rendered)
330
- flow.out.push(rendered);
446
+ const block = renderBlock(node);
447
+ if (block.text)
448
+ flow.out.push(block);
331
449
  continue;
332
450
  }
451
+ flow.run.open(tag);
333
452
  walkFlow(childrenOf(node), flow, blockTags);
453
+ flow.run.close(tag);
334
454
  }
335
455
  }
336
- /** Ordered text blocks contributed by one `<p>` — or any other flow container. */
337
- function flowBlocks(nodes, blockTags = BLOCK_TAGS) {
338
- const flow = { out: [], run: '' };
456
+ /** Ordered blocks contributed by one `<p>` — or any other flow container — with their list edges. */
457
+ function flowParts(nodes, blockTags = BLOCK_TAGS) {
458
+ const flow = { out: [], run: new TextAssembler() };
339
459
  walkFlow(nodes, flow, blockTags);
340
460
  flushRun(flow);
341
461
  return flow.out;
342
462
  }
463
+ /** Ordered text blocks contributed by one `<p>` — or any other flow container. */
464
+ function flowBlocks(nodes, blockTags = BLOCK_TAGS) {
465
+ return flowParts(nodes, blockTags).map((block) => block.text);
466
+ }
343
467
  /**
344
468
  * Block tags for a container whose `<title>` and `<p>` children each carry a
345
469
  * statement of their own — a `<caption>`, an `<abstract>`, an abstract `<sec>`.
@@ -369,30 +493,42 @@ function renderCaption(caption) {
369
493
  * sat; `<ref-list>` is carried by `references[]` and likewise contributes
370
494
  * nothing, which is what keeps a `<sec>` that only wraps one from surviving as
371
495
  * an empty "References" heading. (#116, #130)
496
+ *
497
+ * A `<list>`, `<def-list>` or `<array>`, and a delimited `<boxed-text>`, stand on
498
+ * lines of their own at both edges; a container read through the default arm
499
+ * takes the edges of its first and last blocks. (#202, #206, #210)
372
500
  */
373
501
  function renderBlock(node) {
374
502
  switch (tagNameOf(node)) {
375
503
  case 'table-wrap':
376
504
  case 'ref-list':
377
- return '';
505
+ return flowBlock('');
378
506
  case 'fig':
379
- return assetMarker(node, 'Figure');
507
+ return flowBlock(assetMarker(node, 'Figure'));
380
508
  case 'supplementary-material':
381
- return assetMarker(node, 'Supplementary');
509
+ return flowBlock(assetMarker(node, 'Supplementary'));
382
510
  case 'list':
383
- return renderList(node, 0);
511
+ return flowBlock(renderList(node, 0), true);
384
512
  case 'def-list':
385
- return renderDefList(node, 0);
513
+ return flowBlock(renderDefList(node, 0), true);
514
+ case 'array':
515
+ return flowBlock(renderArray(node), true);
386
516
  case 'disp-quote':
387
- return renderDispQuote(node);
517
+ return flowBlock(renderDispQuote(node));
388
518
  case 'boxed-text':
389
- return renderBoxedText(node);
519
+ return flowBlock(renderFloatingBox(node), true);
390
520
  case 'preformat':
391
- return renderPreformat(node);
521
+ return flowBlock(renderPreformat(node));
392
522
  case 'disp-formula':
393
- return renderDispFormula(node);
394
- default:
395
- return flowBlocks(childrenOf(node)).join('\n\n');
523
+ return flowBlock(renderDispFormula(node));
524
+ default: {
525
+ const blocks = flowParts(childrenOf(node));
526
+ return {
527
+ text: blocks.map((block) => block.text).join('\n\n'),
528
+ ownLineFirst: blocks[0]?.ownLineFirst ?? false,
529
+ ownLineLast: blocks.at(-1)?.ownLineLast ?? false,
530
+ };
531
+ }
396
532
  }
397
533
  }
398
534
  /**
@@ -458,6 +594,25 @@ function assetMarker(node, kind) {
458
594
  * `<def-list>` nested inside a `<list-item>` — both in the JATS content model —
459
595
  * indents one level per depth. (#130)
460
596
  *
597
+ * Publishers nest a sub-list either as a child of the `<list-item>` or inside
598
+ * the item's `<p>` (PMC12892673), and both render the same way: below the
599
+ * item's line, one level deeper. Read as paragraph flow instead, the inner
600
+ * list's first item ran onto its parent's line. The item's `<p>` is split at
601
+ * the lists it holds, so the item reads in document order: prose before the
602
+ * first nested list stays on the item's line, and prose after one — the rest
603
+ * of that `<p>` or a later `<p>` — keeps its place below the list on a line of
604
+ * its own, one level deeper, rather than running onto the last nested item
605
+ * (PMC8533647) or moving above the list onto the item's line. An item whose
606
+ * text all follows its nested list still prints its marker, alone on the
607
+ * item's line, so that text stays under its own item rather than reading as
608
+ * the item before it; an item holding nothing but a nested list prints no line
609
+ * of its own. (#200, #203)
610
+ *
611
+ * A `simple` list prints no marker of its own, so an item's `<label>` is its
612
+ * only numbering and leads the item's line (`1. Cereals …`); dropping it lost
613
+ * the source numbering of every labeled simple list. Other list types print
614
+ * their own marker and leave the label out. (#185)
615
+ *
461
616
  * `withTitle: false` leaves the title out, for a list whose title has become
462
617
  * the heading of the section it forms (see {@link extractBodySections}).
463
618
  */
@@ -474,26 +629,64 @@ function renderList(list, depth, withTitle = true) {
474
629
  const marker = listType === 'simple' ? '' : listType === 'order' ? `${ordinal}. ` : '- ';
475
630
  const parts = [];
476
631
  const nested = [];
632
+ /** Prose read after a nested list, kept below that list. */
633
+ let after;
634
+ let proseAfter = false;
635
+ const flushAfter = () => {
636
+ const text = after?.filter(Boolean).join(' ');
637
+ if (!text)
638
+ return;
639
+ nested.push(`${pad}${NESTED_LIST_INDENT}${text}`);
640
+ proseAfter = true;
641
+ };
642
+ const addNested = (block) => {
643
+ flushAfter();
644
+ nested.push(block);
645
+ after = [];
646
+ };
477
647
  for (const child of childrenOf(item)) {
478
648
  const tag = tagNameOf(child);
479
- if (tag === 'label')
649
+ if (tag === 'label') {
650
+ if (listType === 'simple' && parts.length === 0)
651
+ parts.push(textContent(child));
480
652
  continue;
481
- if (tag === 'list')
482
- nested.push(renderList(child, depth + 1));
483
- else if (tag === 'def-list')
484
- nested.push(renderDefList(child, depth + 1));
485
- else
486
- parts.push(...flowBlocks([child]));
653
+ }
654
+ // A `<p>` is read child by child, so a list inside it splits its prose;
655
+ // any other child is one unit, itself a nested list or not.
656
+ let prose = [];
657
+ for (const node of tag === 'p' ? childrenOf(child) : [child]) {
658
+ const inner = renderNestedList(node, depth + 1);
659
+ if (inner === undefined) {
660
+ prose.push(node);
661
+ continue;
662
+ }
663
+ (after ?? parts).push(...flowBlocks(prose));
664
+ prose = [];
665
+ addNested(inner);
666
+ }
667
+ (after ?? parts).push(...flowBlocks(prose));
487
668
  }
488
- const text = parts.join(' ');
669
+ flushAfter();
670
+ const text = parts.filter(Boolean).join(' ');
489
671
  if (text)
490
672
  lines.push(`${pad}${marker}${text}`);
673
+ else if (proseAfter && marker)
674
+ lines.push(`${pad}${marker.trimEnd()}`);
491
675
  for (const block of nested)
492
676
  if (block)
493
677
  lines.push(block);
494
678
  }
495
679
  return lines.join('\n');
496
680
  }
681
+ /** A `<list>` or `<def-list>` rendered at `depth`; undefined for any other node. (#200) */
682
+ function renderNestedList(node, depth) {
683
+ const tag = tagNameOf(node);
684
+ if (tag === 'list')
685
+ return renderList(node, depth);
686
+ if (tag === 'def-list')
687
+ return renderDefList(node, depth);
688
+ return;
689
+ }
497
690
  /**
498
691
  * Render a `<def-list>`: its title on a line of its own, then
499
692
  * `- term — definition` per item. `withTitle: false` leaves the title out, as
@@ -514,6 +707,28 @@ function renderDefList(defList, depth, withTitle = true) {
514
707
  }
515
708
  return lines.join('\n');
516
709
  }
710
+ /**
711
+ * Render an `<array>` — tabular material with no caption of its own, such as a
712
+ * glossary's grid of abbreviations — one row per line, its cells joined the way
713
+ * {@link renderDefList} joins a term to its definition:
714
+ * `- RSNA — Radiological Society of North America`. Read as flow, every cell ran
715
+ * into the next (`RSNARadiological Society of North AmericaRT-PCR…`). (#206)
716
+ */
717
+ function renderArray(array) {
718
+ const lines = [];
719
+ for (const row of findAllDescendants(array, 'tr')) {
720
+ const cells = childrenOf(row)
721
+ .filter((cell) => {
722
+ const tag = tagNameOf(cell);
723
+ return tag === 'td' || tag === 'th';
724
+ })
725
+ .map((cell) => textContent(cell))
726
+ .filter(Boolean);
727
+ if (cells.length > 0)
728
+ lines.push(`- ${cells.join(' — ')}`);
729
+ }
730
+ return lines.join('\n');
731
+ }
517
732
  /** Render a `<disp-quote>`: every line quoted, its `<attrib>` as a trailing attribution line. */
518
733
  function renderDispQuote(quote) {
519
734
  const lines = [];
@@ -541,9 +756,10 @@ function renderDispQuote(quote) {
541
756
  *
542
757
  * `withTitle: false` leaves out the `<caption>`'s `<title>` — the box's title in
543
758
  * the JATS content model — for a box whose title has become the heading of the
544
- * section it forms (see {@link extractBodySections}).
759
+ * section it forms (see {@link extractBodySections}). `withLabel: false` leaves
760
+ * out its `<label>`, for a box whose label {@link renderFloatingBox} names it by.
545
761
  */
546
- function renderBoxedText(boxedText, withTitle = true) {
762
+ function renderBoxedText(boxedText, { withLabel = true, withTitle = true } = {}) {
547
763
  const blocks = [];
548
764
  for (const child of childrenOf(boxedText)) {
549
765
  const tag = tagNameOf(child);
@@ -553,11 +769,38 @@ function renderBoxedText(boxedText, withTitle = true) {
553
769
  const parts = childrenOf(child).filter((c) => withTitle || tagNameOf(c) !== 'title');
554
770
  blocks.push(...flowBlocks(parts, STATEMENT_BLOCK_TAGS));
555
771
  }
556
- else
772
+ else if (withLabel || tag !== 'label')
557
773
  blocks.push(...flowBlocks([child]));
558
774
  }
559
775
  return blocks.join('\n\n');
560
776
  }
777
+ /** The line that closes a {@link renderFloatingBox} box. */
778
+ const BOX_END = '[End of box]';
779
+ /**
780
+ * Render a `<boxed-text>` that sits in the body flow — a "Box 1", a highlights
781
+ * panel, a sidebar — set apart from the prose around it. Read as plain
782
+ * paragraphs, nothing marked where the box ended, so the body text after it
783
+ * read as part of the box (PMC10666927). The box opens with a line naming it by
784
+ * its label and `<caption>` title (`[Box: Box 1. Virtual unconference format]`,
785
+ * or `[Box]` when it carries neither), its content follows as
786
+ * {@link renderBoxedText} reads it, and {@link BOX_END} closes it. A box with no
787
+ * readable content — a lone `<graphic>` — keeps only its opening line when it
788
+ * is named, and renders nothing when it is not. (#210)
789
+ *
790
+ * A box that forms a section of its own (see {@link extractBodySections})
791
+ * needs none of this: its title is the section heading, and the next heading
792
+ * ends it.
793
+ */
794
+ function renderFloatingBox(boxedText) {
795
+ const label = textContent(findOne(boxedText, 'label'));
796
+ const title = textContent(findOne(findOne(boxedText, 'caption'), 'title'));
797
+ const heading = [label, title].filter(Boolean).join(' ');
798
+ const opening = heading ? `[Box: ${heading}]` : '[Box]';
799
+ const content = renderBoxedText(boxedText, { withLabel: false, withTitle: false });
800
+ if (!content)
801
+ return heading ? opening : '';
802
+ return [opening, content, BOX_END].join('\n\n');
803
+ }
561
804
  /**
562
805
  * A `<sec>` subtree as flat text blocks: each heading on its own line above its
563
806
  * prose, descendants following in document order. Used where a section has no
@@ -597,7 +840,7 @@ function renderPreformat(preformat) {
597
840
  const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
598
841
  /**
599
842
  * Render a `<disp-formula>`: its label, then a `<tex-math>` where the deposit
600
- * carries one (directly or under `<alternatives>`) and the flattened content
843
+ * carries one (directly or under `<alternatives>`) and the content's text
601
844
  * otherwise — usually `<mml:math>`, which is 69 of the 78 formulae in a
602
845
  * 68-record draw against 3 for `<tex-math>`. A graphic-only deposit has no
603
846
  * fallback text and contributes nothing at all rather than a bare label on an
@@ -606,6 +849,9 @@ const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
606
849
  * A `<tex-math>` contributes only the expression between `\begin{document}` and
607
850
  * `\end{document}`; `textContent` applies that rule, so the LaTeX preamble
608
851
  * publishers wrap around every formula never reaches the rendered line. (#135)
852
+ * A MathML `<math>` reads in the TeX-style linear form `textContent` gives it
853
+ * (`(L & M \\ M^{T} & N)<0`), so a formula keeps its scripts and matrix cells
854
+ * whichever notation the deposit carries. (#207)
609
855
  */
610
856
  function renderDispFormula(formula) {
611
857
  const texMath = findOne(formula, 'tex-math') ?? findOne(findOne(formula, 'alternatives'), 'tex-math');
@@ -638,8 +884,24 @@ function renderDispFormula(formula) {
638
884
  * content and stay in untitled sections of their own. (#148)
639
885
  */
640
886
  export function extractBodySections(body) {
641
- if (!body)
642
- return [];
887
+ return body ? extractSections(childrenOf(body), false) : [];
888
+ }
889
+ /**
890
+ * Extract back-matter sections from a `<back>` node: every child but
891
+ * `<ref-list>` — which `references[]` carries — and `<back>`'s own label and
892
+ * title, in source order. Each back-matter element is read as a `<sec>` is (see
893
+ * {@link BACK_SECTION_TAGS}), taking the title its element names when it carries
894
+ * neither a title nor a label (see {@link BACK_MATTER_TITLES}). (#206)
895
+ */
896
+ function extractBackSections(back) {
897
+ return back ? extractSections(childrenOf(back), true) : [];
898
+ }
899
+ /**
900
+ * Read the children of `<body>` — or, with `back`, of `<back>` — into sections,
901
+ * in document order. {@link extractBodySections} states the rules; back matter
902
+ * adds its own section elements and skips `<back>`'s label and title.
903
+ */
904
+ function extractSections(children, back) {
643
905
  const sections = [];
644
906
  let pendingBlocks = [];
645
907
  const flushPending = () => {
@@ -648,14 +910,15 @@ export function extractBodySections(body) {
648
910
  pendingBlocks = [];
649
911
  }
650
912
  };
651
- for (const child of childrenOf(body)) {
652
- if (tagNameOf(child) === 'sec') {
913
+ for (const child of children) {
914
+ const nested = nestedSections(child, back);
915
+ if (nested) {
653
916
  flushPending();
654
- const section = extractSection(child);
655
- if (section)
656
- sections.push(section);
917
+ sections.push(...nested);
657
918
  continue;
658
919
  }
920
+ if (back && SECTION_META_TAGS.has(tagNameOf(child) ?? ''))
921
+ continue;
659
922
  const title = ownBlockTitle(child);
660
923
  if (title) {
661
924
  flushPending();
@@ -667,6 +930,90 @@ export function extractBodySections(body) {
667
930
  flushPending();
668
931
  return sections;
669
932
  }
933
+ // ─── Back Matter ────────────────────────────────────────────────────────────
934
+ /**
935
+ * Back-matter elements read as sections, each mapped to the title it goes by
936
+ * when it carries neither a `<title>` nor a `<label>`. Most `<fn-group>`s carry
937
+ * neither, and without a title a section renders as `untitled section` where no
938
+ * `sections` filter term can reach it. The title names the element, not a fact
939
+ * about the article; Europe PMC titles the same `<fn-group>` `Footnotes` when it
940
+ * moves back matter into `<body>`. A labelled element takes no fallback, so an
941
+ * `Appendix A` label never reads `Appendix Appendix A`. (#206)
942
+ */
943
+ const BACK_MATTER_TITLES = new Map([
944
+ ['ack', 'Acknowledgments'],
945
+ ['app', 'Appendix'],
946
+ ['bio', 'Biography'],
947
+ ['fn-group', 'Footnotes'],
948
+ ['glossary', 'Glossary'],
949
+ ['notes', 'Notes'],
950
+ ]);
951
+ /**
952
+ * Elements that form a section of their own in back matter: `<sec>`,
953
+ * `<app-group>`, and each element {@link BACK_MATTER_TITLES} names. One nested in
954
+ * another is a subsection, as a `<sec>` in a `<sec>` is, so a Springer
955
+ * `Declarations` `<notes>` keeps its inner notes' titles out of its text. In
956
+ * `<body>` only a `<sec>` forms one: a `<notes>` or `<fn-group>` closing a body
957
+ * `<sec>` stays that section's text, as it always read. (#206)
958
+ */
959
+ const BACK_SECTION_TAGS = new Set([
960
+ 'sec',
961
+ 'app-group',
962
+ ...BACK_MATTER_TITLES.keys(),
963
+ ]);
964
+ /**
965
+ * True for an `<app-group>` with neither a title nor a label. It has no heading
966
+ * to give a section, so its children are read as if they sat in `<back>`: its
967
+ * `<app>`s stand as sections in its place, and the paragraphs, lists or figures
968
+ * JATS lets it carry ahead of them form an untitled section rather than being
969
+ * dropped. (#206)
970
+ */
971
+ function isHeadlessAppGroup(node) {
972
+ return (tagNameOf(node) === 'app-group' &&
973
+ !textContent(findOne(node, 'title')) &&
974
+ !textContent(findOne(node, 'label')));
975
+ }
976
+ /**
977
+ * The sections `node` forms where it sits among a section's children, or among
978
+ * `<body>`'s or `<back>`'s: a `<sec>` anywhere, and in back matter each element
979
+ * of {@link BACK_SECTION_TAGS} — a headless `<app-group>` by way of its
980
+ * children (see {@link isHeadlessAppGroup}). Undefined for anything that is
981
+ * section content instead. (#206)
982
+ */
983
+ function nestedSections(node, back) {
984
+ const tag = tagNameOf(node) ?? '';
985
+ if (tag !== 'sec' && !(back && BACK_SECTION_TAGS.has(tag)))
986
+ return;
987
+ if (isHeadlessAppGroup(node))
988
+ return extractSections(childrenOf(node), back);
989
+ const section = extractSection(node, back);
990
+ return section ? [section] : [];
991
+ }
992
+ /**
993
+ * The title a back-matter element with neither a `<title>` nor a `<label>` goes
994
+ * by (see {@link BACK_MATTER_TITLES}); undefined for every other element, a
995
+ * `<sec>` included. (#206)
996
+ */
997
+ function fallbackTitle(node) {
998
+ if (textContent(findOne(node, 'label')))
999
+ return;
1000
+ return BACK_MATTER_TITLES.get(tagNameOf(node) ?? '');
1001
+ }
1002
+ /**
1003
+ * The paragraphs of a back-matter `<fn>`: each `<p>`, and each block one holds,
1004
+ * on its own, with the footnote's `<label>` leading the first. Read as one run,
1005
+ * a footnote whose heading sits in a `<p>` of its own fused that heading into
1006
+ * the statement after it (`Competing interests The authors declare…`), the
1007
+ * fusion a titled `<notes>` is kept from. (#206)
1008
+ */
1009
+ function footnoteParagraphs(fn) {
1010
+ const label = textContent(findOne(fn, 'label'));
1011
+ const [first, ...rest] = flowBlocks(childrenOf(fn).filter((child) => tagNameOf(child) !== 'label'), STATEMENT_BLOCK_TAGS);
1012
+ if (first === undefined)
1013
+ return label ? [label] : [];
1014
+ return [label ? `${label} ${first}` : first, ...rest];
1015
+ }
1016
+ // ─── Section Reading ────────────────────────────────────────────────────────
670
1017
  /**
671
1018
  * The title a block carries of its own, where the JATS content model puts one:
672
1019
  * a direct `<title>` on a `<def-list>` or `<list>`, the `<caption>` title on a
@@ -706,13 +1053,14 @@ function loneTitledBlock(sec) {
706
1053
  return title ? { node, title } : undefined;
707
1054
  }
708
1055
  /**
709
- * The title a `<sec>` goes by in the output: its own `<title>`, else the one
710
- * {@link loneTitledBlock} lifts. {@link extractSection} titles the section and
711
- * {@link collectSectioned} names the tables and assets inside it from this one
712
- * rule, so a `sections` filter keeps them together. (#169)
1056
+ * The title a `<sec>` — or a back-matter element read as one — goes by in the
1057
+ * output: its own `<title>`, else the one {@link loneTitledBlock} lifts, else
1058
+ * the one {@link fallbackTitle} names. {@link extractSection} titles the section
1059
+ * and {@link collectSectioned} names the tables and assets inside it from this
1060
+ * one rule, so a `sections` filter keeps them together. (#169, #206)
713
1061
  */
714
1062
  function secTitle(sec) {
715
- return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title;
1063
+ return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title || fallbackTitle(sec);
716
1064
  }
717
1065
  /** A block whose {@link ownBlockTitle} became its section's title, rendered without it. */
718
1066
  function renderBlockWithoutTitle(node) {
@@ -722,9 +1070,9 @@ function renderBlockWithoutTitle(node) {
722
1070
  case 'list':
723
1071
  return renderList(node, 0, false);
724
1072
  case 'boxed-text':
725
- return renderBoxedText(node, false);
1073
+ return renderBoxedText(node, { withTitle: false });
726
1074
  default:
727
- return renderBlock(node);
1075
+ return renderBlock(node).text;
728
1076
  }
729
1077
  }
730
1078
  /**
@@ -736,8 +1084,15 @@ function renderBlockWithoutTitle(node) {
736
1084
  *
737
1085
  * An untitled `<sec>` whose only content is a titled block takes that block's
738
1086
  * title, and the block renders without it (see {@link loneTitledBlock}). (#169)
1087
+ *
1088
+ * With `back`, `sec` may be any back-matter element {@link nestedSections}
1089
+ * reads as a section: the back-matter elements among its children are
1090
+ * subsections too, and with neither a title nor a label it takes the title its
1091
+ * element names. Each child contributes its own block, so every `<fn>` of a
1092
+ * `<fn-group>` is a paragraph of its own, and each `<p>` inside one too (see
1093
+ * {@link footnoteParagraphs}). (#206)
739
1094
  */
740
- function extractSection(sec) {
1095
+ function extractSection(sec, back = false) {
741
1096
  const lone = loneTitledBlock(sec);
742
1097
  if (lone) {
743
1098
  const label = textContent(findOne(sec, 'label')) || undefined;
@@ -757,14 +1112,14 @@ function extractSection(sec) {
757
1112
  label ??= textContent(child) || undefined;
758
1113
  continue;
759
1114
  }
760
- if (tag === 'sec') {
761
- const subsection = extractSection(child);
762
- if (subsection)
763
- subsections.push(subsection);
1115
+ const nested = nestedSections(child, back);
1116
+ if (nested) {
1117
+ subsections.push(...nested);
764
1118
  continue;
765
1119
  }
766
- textParts.push(...flowBlocks([child]));
1120
+ textParts.push(...(back && tag === 'fn' ? footnoteParagraphs(child) : flowBlocks([child])));
767
1121
  }
1122
+ title ??= fallbackTitle(sec);
768
1123
  const text = textParts.join('\n\n');
769
1124
  // A section left empty *because* its block content was lifted into `tables[]`
770
1125
  // survives as a heading-only entry: the heading is what places the table for a
@@ -787,10 +1142,10 @@ function extractSection(sec) {
787
1142
  * The walk covers the whole article rather than `<body>` alone: about a quarter
788
1143
  * of real tables sit in `<floats-group>`, `<back>/<sec>`, `<table-wrap-group>`
789
1144
  * or `<app-group>/<app>`, so a body-scoped extractor drops them. Each table
790
- * names the innermost enclosing `<sec>` wherever that sits — a back-matter or
791
- * appendix section counts, and naming it is the reader's only positional cue
792
- * there. Only a table inside no `<sec>` at all, such as a `<floats-group>`
793
- * deposit, carries no section name.
1145
+ * names the innermost enclosing section wherever that sits — a back-matter
1146
+ * `<sec>`, `<app>`, `<notes>` or `<fn-group>` counts, and naming it is the
1147
+ * reader's only positional cue there. Only a table inside no section at all,
1148
+ * such as a `<floats-group>` deposit, carries no section name. (#206)
794
1149
  *
795
1150
  * Only XHTML `<tr>`/`<td>`/`<th>` bodies are read. **Decision, not an
796
1151
  * oversight:** across a 283-table survey of open-access records, 276 were XHTML,
@@ -813,19 +1168,21 @@ export function extractPmcTables(root) {
813
1168
  * positional cue, and an element inside no section at all gets none. A
814
1169
  * recognized element is not descended into — it parses its own subtree.
815
1170
  *
816
- * A section here is whatever {@link extractBodySections} reports as one, under
817
- * the title it reports: a `<sec>` by {@link secTitle}, and a titled block
818
- * sitting directly in `<body>` by its own title — so a figure in a titled
819
- * body-level box names the box, and a `sections` filter on the box keeps it.
820
- * A `<sec>` inside a `<boxed-text>` is no section: the box flattens it into its
821
- * text (see {@link renderBoxedText}), so what sits in it names the section the
822
- * box belongs to. (#169)
1171
+ * A section here is whatever {@link extractBodySections} and
1172
+ * {@link extractBackSections} report as one, under the title they report: a
1173
+ * `<sec>` — and inside `<back>`, each back-matter element read as one — by
1174
+ * {@link secTitle}, and a titled block sitting directly in `<body>` or `<back>`
1175
+ * by its own title — so a figure in a titled body-level box names the box, and
1176
+ * a `sections` filter on the box keeps it. A `<sec>` inside a `<boxed-text>` is
1177
+ * no section: the box flattens it into its text (see {@link renderBoxedText}),
1178
+ * so what sits in it names the section the box belongs to. (#169, #206)
823
1179
  *
824
1180
  * Shared by the table and asset walks so the section-title rule has one
825
1181
  * definition and cannot drift between them.
826
1182
  */
827
- function collectSectioned(node, sectionTitle, out, take, inBox = false) {
828
- const inBody = tagNameOf(node) === 'body';
1183
+ function collectSectioned(node, sectionTitle, out, take, inBox = false, inBack = false) {
1184
+ const parentTag = tagNameOf(node);
1185
+ const atTopLevel = parentTag === 'body' || parentTag === 'back';
829
1186
  for (const child of childrenOf(node)) {
830
1187
  const tag = tagNameOf(child);
831
1188
  if (!tag)
@@ -837,19 +1194,21 @@ function collectSectioned(node, sectionTitle, out, take, inBox = false) {
837
1194
  }
838
1195
  const own = inBox
839
1196
  ? undefined
840
- : tag === 'sec'
1197
+ : tag === 'sec' || (inBack && BACK_SECTION_TAGS.has(tag))
841
1198
  ? secTitle(child)
842
- : inBody
1199
+ : atTopLevel
843
1200
  ? ownBlockTitle(child)
844
1201
  : undefined;
845
- collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text');
1202
+ collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text', inBack || tag === 'back');
846
1203
  }
847
1204
  }
848
1205
  function parseTableWrap(tableWrap, sectionTitle) {
849
1206
  const id = attrOf(tableWrap, 'id');
850
1207
  const label = textContent(findOne(tableWrap, 'label')) || undefined;
851
1208
  const caption = renderCaption(findOne(tableWrap, 'caption'));
852
- const footnotes = textContent(findOne(tableWrap, 'table-wrap-foot')) || undefined;
1209
+ // One line per footnote or paragraph: a footer is a list of statements, and a
1210
+ // marker run into the previous note (`…dose.*No patient…`) misattributes it. (#185)
1211
+ const footnotes = lineTextContent(findOne(tableWrap, 'table-wrap-foot')) || undefined;
853
1212
  // Some deposits offer both renderings inside <alternatives>; prefer the markup.
854
1213
  const table = findOne(tableWrap, 'table') ?? findOne(findOne(tableWrap, 'alternatives'), 'table');
855
1214
  const { rows, headerRowCount } = table
@@ -940,7 +1299,8 @@ function extractTableRows(table) {
940
1299
  allHeaderCells = false;
941
1300
  sourceCells += 1;
942
1301
  col = drainCarried(row, col);
943
- const value = textContent(cell);
1302
+ // `\n` wherever the cell breaks a line: `1.08<break/>2.44` is two values. (#185)
1303
+ const value = lineTextContent(cell);
944
1304
  const rowspan = spanOf(cell, 'rowspan');
945
1305
  const width = Math.min(spanOf(cell, 'colspan'), MAX_TABLE_COLUMNS - col);
946
1306
  for (let i = 0; i < width; i++) {
@@ -985,9 +1345,10 @@ function extractTableRows(table) {
985
1345
  * supplementary material sit outside `<body>` entirely — `<floats-group>`
986
1346
  * deposits, `<back>/<sec>` and `<app-group>/<app>` placements, and figures
987
1347
  * hanging off the abstract in `<front>`. Each asset names the innermost
988
- * enclosing `<sec>` wherever that sits, an untitled one inheriting its parent's
989
- * title, so the reader keeps a positional cue; an asset inside no `<sec>` at all
990
- * carries no section name. (#130)
1348
+ * enclosing section wherever that sits — a back-matter element read as one
1349
+ * included — an untitled `<sec>` inheriting its parent's title, so the reader
1350
+ * keeps a positional cue; an asset inside no section at all carries no section
1351
+ * name. (#130, #206)
991
1352
  */
992
1353
  export function extractPmcAssets(root) {
993
1354
  if (!root)
@@ -1059,30 +1420,41 @@ function renderCitationName(node) {
1059
1420
  * direct children in document order, joining author names with `, ` and
1060
1421
  * labeling typed `<pub-id>`s, so the output reads as a citation rather than a
1061
1422
  * token run. (#69)
1423
+ *
1424
+ * Two field pairs are joined the way a citation prints them rather than by a
1425
+ * space, which read as two unrelated numbers: an `<fpage>` followed by its
1426
+ * `<lpage>` is a page range (`56–71`, `i1–i51`), and a `<volume>` followed by
1427
+ * its `<issue>` reads `16(1)`. Each token is kept verbatim. (#209)
1062
1428
  */
1063
1429
  function renderElementCitation(node) {
1064
1430
  const parts = [];
1431
+ /** The field element that contributed the last part; undefined after anything else. */
1432
+ let previous;
1065
1433
  for (const child of childrenOf(node)) {
1066
1434
  const tag = tagNameOf(child);
1435
+ let part;
1067
1436
  if (tag === 'person-group') {
1068
- const names = childrenOf(child).map(renderCitationName).filter(Boolean);
1069
- if (names.length > 0)
1070
- parts.push(names.join(', '));
1437
+ part = childrenOf(child).map(renderCitationName).filter(Boolean).join(', ');
1071
1438
  }
1072
1439
  else if (tag === 'pub-id') {
1073
1440
  const value = textContent(child);
1074
- if (value) {
1075
- const label = PUB_ID_LABELS[attrOf(child, 'pub-id-type') ?? ''];
1076
- parts.push(label ? `${label} ${value}` : value);
1077
- }
1441
+ const label = PUB_ID_LABELS[attrOf(child, 'pub-id-type') ?? ''];
1442
+ part = value && label ? `${label} ${value}` : value;
1078
1443
  }
1079
1444
  else {
1080
1445
  // Element field (source, volume, year, fpage, …) or a bare text node;
1081
1446
  // textContent renders both, and empty results drop out of the join.
1082
- const text = textContent(child);
1083
- if (text)
1084
- parts.push(text);
1447
+ part = textContent(child);
1085
1448
  }
1449
+ if (!part)
1450
+ continue;
1451
+ if (tag === 'lpage' && previous === 'fpage')
1452
+ parts.push(`${parts.pop()}–${part}`);
1453
+ else if (tag === 'issue' && previous === 'volume')
1454
+ parts.push(`${parts.pop()}(${part})`);
1455
+ else
1456
+ parts.push(part);
1457
+ previous = tag;
1086
1458
  }
1087
1459
  return parts.join(' ');
1088
1460
  }
@@ -1096,38 +1468,138 @@ function renderElementCitation(node) {
1096
1468
  */
1097
1469
  const NAME_WRAPPER_TAGS = new Set(['name', 'string-name', 'person-group']);
1098
1470
  /**
1099
- * Serialize a `<name>` / `<string-name>` / `<person-group>` subtree, applying
1100
- * the same rule `renderMixedCitation` applies to its own children: a single
1101
- * space between two adjacent elements that carry nothing between them, and
1102
- * source text emitted verbatim. `<person-group>` separates its names with real
1103
- * `, ` text nodes and `<string-name>` usually separates surname from given
1104
- * names with a newline, so a fixed separator would double punctuation the
1105
- * source already has — spacing only the zero-gap transitions leaves those
1106
- * records byte-identical. Returns raw text; the caller collapses whitespace
1107
- * once over the finished citation.
1471
+ * The members of an author list: each names one contributor, or (`<etal>`)
1472
+ * stands for the rest. Two of them with nothing but whitespace between them are
1473
+ * two list entries, which the source left for the reader to separate. (#209)
1474
+ */
1475
+ const PERSON_TAGS = new Set(['name', 'string-name', 'collab', 'etal']);
1476
+ /** What an empty `<etal/>` stands for. (#209) */
1477
+ const SUPPLIED_ETAL = 'et al.';
1478
+ /** Any character other than whitespace. */
1479
+ const VISIBLE_TEXT = /\S/;
1480
+ /** Punctuation that closes the citation field before it: `Henry L.`, `Li X,`, `(2020)`. (#219) */
1481
+ const CLOSING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
1482
+ /**
1483
+ * How much of the citation written so far a `<pub-id>` reads for a literal
1484
+ * prefix naming its type: the prefix, its `:` or `.`, and the whitespace around
1485
+ * them. A bounded window keeps each read constant-time, since every `<pub-id>`
1486
+ * in a citation asks again and reading the whole text would make the walk
1487
+ * quadratic. (#209)
1488
+ */
1489
+ const ID_PREFIX_WINDOW = 256;
1490
+ /**
1491
+ * Render one child element of a `<mixed-citation>` or name wrapper. `before` is
1492
+ * the end of the citation text written so far ({@link ID_PREFIX_WINDOW}), which
1493
+ * a `<pub-id>` reads for a literal prefix already naming its type. An `<etal>` reads as its own text, or as
1494
+ * `et al.` when it is empty — as nearly every deposit leaves it.
1108
1495
  */
1109
- function renderNameWrapper(node) {
1110
- let rendered = '';
1111
- let prevWasElement = false;
1496
+ function renderCitationPart(child, tag, before) {
1497
+ if (tag === 'pub-id') {
1498
+ const value = textContent(child);
1499
+ const type = attrOf(child, 'pub-id-type') ?? '';
1500
+ const label = PUB_ID_LABELS[type];
1501
+ if (value && label && !hasLiteralIdPrefix(before, type)) {
1502
+ return { text: `${label} ${value}`, labeled: true, suppliedEtal: false };
1503
+ }
1504
+ return { text: value, labeled: false, suppliedEtal: false };
1505
+ }
1506
+ if (NAME_WRAPPER_TAGS.has(tag))
1507
+ return { ...renderCitationRun(child, true), labeled: false };
1508
+ const text = rawTextContent(child);
1509
+ if (tag === 'etal' && !text)
1510
+ return { text: SUPPLIED_ETAL, labeled: false, suppliedEtal: true };
1511
+ return { text, labeled: false, suppliedEtal: false };
1512
+ }
1513
+ /**
1514
+ * Serialize a `<mixed-citation>` (`wrapper: false`) or a `<name>` /
1515
+ * `<string-name>` / `<person-group>` inside one (`wrapper: true`), emitting
1516
+ * source text verbatim and writing a separator only where the source left
1517
+ * none:
1518
+ *
1519
+ * - Two adjacent author-list members ({@link PERSON_TAGS}) with nothing but
1520
+ * whitespace between them are joined by `, `: `Zwierenga F, van Veggel B,
1521
+ * Hendriks LEL, et al.` rather than `Zwierenga F van Veggel B Hendriks LEL`.
1522
+ * (#209)
1523
+ * - Any other two elements with nothing between them get one space: a
1524
+ * `<surname>` and its `<given-names>`, a title and the volume after it.
1525
+ * (#115, #123, #124)
1526
+ * - A label this renderer inserts is separated from whatever precedes it. (#115)
1527
+ * - A source `.` straight after a supplied `et al.` closes it rather than
1528
+ * doubling its period (`<etal/></person-group>. <article-title>`). (#209)
1529
+ * - The whitespace between an element and the {@link CLOSING_PUNCTUATION}
1530
+ * after it is dropped when any of it lies inside the element or it holds a
1531
+ * line break: the indentation a pretty-printed deposit leaves before
1532
+ * `</person-group>` or `</string-name>`, or the line break after
1533
+ * `</collab>`, reads `Henry L.` rather than `Henry L .`. Spaces the
1534
+ * citation's own text puts there (`</source> : <volume>`) are kept. (#219)
1535
+ *
1536
+ * Gaps the source already fills — `, ` text between names, a newline between a
1537
+ * `<string-name>`'s parts — are kept as written, so a fixed separator never
1538
+ * doubles punctuation the source has. Inside a wrapper an element that renders
1539
+ * nothing is skipped; at the citation's own level it still counts as the
1540
+ * element the next one follows. Returns raw text; the caller collapses
1541
+ * whitespace once over the finished citation.
1542
+ *
1543
+ * Whitespace is held in `gap` until what follows it decides whether it is
1544
+ * written, so no step re-reads the text already written and the cost stays
1545
+ * linear in the size of the citation.
1546
+ */
1547
+ function renderCitationRun(node, wrapper) {
1548
+ let text = '';
1549
+ /** Whitespace not yet written: the end of the last element's text, then source whitespace after it. */
1550
+ let gap = '';
1551
+ /** `gap` begins inside the element written last. */
1552
+ let gapInside = false;
1553
+ /** The element written last, while no visible source text has followed it. */
1554
+ let previous;
1555
+ let suppliedEtal = false;
1556
+ /** At least the last {@link ID_PREFIX_WINDOW} characters of `text`, at most twice that. */
1557
+ let recent = '';
1558
+ const write = (chunk) => {
1559
+ text += chunk;
1560
+ recent += chunk;
1561
+ if (recent.length > 2 * ID_PREFIX_WINDOW)
1562
+ recent = recent.slice(-ID_PREFIX_WINDOW);
1563
+ };
1112
1564
  for (const child of childrenOf(node)) {
1113
1565
  if (isTextNode(child)) {
1114
- const raw = textOf(child);
1115
- if (raw) {
1116
- rendered += raw;
1117
- prevWasElement = false;
1566
+ let raw = textOf(child);
1567
+ if (!VISIBLE_TEXT.test(raw)) {
1568
+ gap += raw;
1569
+ continue;
1118
1570
  }
1571
+ if (suppliedEtal)
1572
+ raw = raw.replace(/^(\s*)\./, '$1');
1573
+ if (previous !== undefined) {
1574
+ const lead = raw.search(VISIBLE_TEXT);
1575
+ if (CLOSING_PUNCTUATION.has(raw.charAt(lead)) &&
1576
+ (gapInside || gap.includes('\n') || raw.slice(0, lead).includes('\n'))) {
1577
+ gap = '';
1578
+ raw = raw.slice(lead);
1579
+ }
1580
+ }
1581
+ write(gap + raw);
1582
+ gap = '';
1583
+ previous = undefined;
1584
+ suppliedEtal = false;
1119
1585
  continue;
1120
1586
  }
1121
1587
  const tag = tagNameOf(child) ?? '';
1122
- const part = NAME_WRAPPER_TAGS.has(tag) ? renderNameWrapper(child) : rawTextContent(child);
1123
- if (!part)
1588
+ const part = renderCitationPart(child, tag, recent);
1589
+ if (!part.text && (wrapper || tag === 'pub-id'))
1124
1590
  continue;
1125
- if (prevWasElement)
1126
- rendered += ' ';
1127
- rendered += part;
1128
- prevWasElement = true;
1591
+ if (previous && PERSON_TAGS.has(previous) && PERSON_TAGS.has(tag))
1592
+ write(', ');
1593
+ else
1594
+ write((previous && !gap) || part.labeled ? `${gap} ` : gap);
1595
+ const body = part.text.trimEnd();
1596
+ write(body);
1597
+ gap = part.text.slice(body.length);
1598
+ gapInside = gap !== '';
1599
+ previous = tag;
1600
+ suppliedEtal = part.suppliedEtal;
1129
1601
  }
1130
- return rendered;
1602
+ return { text: text + gap, suppliedEtal };
1131
1603
  }
1132
1604
  /**
1133
1605
  * True when the citation text emitted so far already ends with a literal prefix
@@ -1158,49 +1630,22 @@ function hasLiteralIdPrefix(rendered, pubIdType) {
1158
1630
  * styles routinely close the prose on punctuation (`… (2020).`, `…021]`) and a
1159
1631
  * label butted against that reads as one token. Source transitions that already
1160
1632
  * carry punctuation or whitespace are emitted unchanged; the trailing whitespace
1161
- * collapse keeps a whitespace-only gap at one space.
1633
+ * collapse keeps a whitespace-only gap at one space. An author list whose names
1634
+ * the source left unseparated reads with `, ` between them and `et al.` for an
1635
+ * empty `<etal/>` (see {@link renderCitationRun}). (#209)
1162
1636
  */
1163
1637
  function renderMixedCitation(node) {
1164
- let rendered = '';
1165
- let prevWasElement = false;
1166
- for (const child of childrenOf(node)) {
1167
- if (isTextNode(child)) {
1168
- const raw = textOf(child);
1169
- if (raw) {
1170
- rendered += raw;
1171
- prevWasElement = false;
1172
- }
1173
- continue;
1174
- }
1175
- const tag = tagNameOf(child) ?? '';
1176
- let part;
1177
- let labeled = false;
1178
- if (tag === 'pub-id') {
1179
- const value = textContent(child);
1180
- if (!value)
1181
- continue;
1182
- const type = attrOf(child, 'pub-id-type') ?? '';
1183
- const label = PUB_ID_LABELS[type];
1184
- if (label && !hasLiteralIdPrefix(rendered, type)) {
1185
- labeled = true;
1186
- part = `${label} ${value}`;
1187
- }
1188
- else {
1189
- part = value;
1190
- }
1191
- }
1192
- else if (NAME_WRAPPER_TAGS.has(tag)) {
1193
- part = renderNameWrapper(child);
1194
- }
1195
- else {
1196
- part = rawTextContent(child);
1197
- }
1198
- if (prevWasElement || labeled)
1199
- rendered += ' ';
1200
- rendered += part;
1201
- prevWasElement = true;
1202
- }
1203
- return rendered.replace(/\s+/g, ' ').trim();
1638
+ return renderCitationRun(node, false).text.replace(/\s+/g, ' ').trim();
1639
+ }
1640
+ /**
1641
+ * A `<ref>`'s `<label>`, without the one pair of square brackets some deposits
1642
+ * print it in: `[19]` is reference 19, and the tool already writes each
1643
+ * reference's label and id inside brackets of its own (`[19 R19]`, not
1644
+ * `[[19] R19]`). Any other label — `1.`, `(3)`, `A12` — is kept as printed.
1645
+ * (#209)
1646
+ */
1647
+ function referenceLabel(label) {
1648
+ return textContent(label).replace(/^\[([^[\]]+)\]$/, '$1') || undefined;
1204
1649
  }
1205
1650
  /**
1206
1651
  * Extract references from anywhere under `root` — pass the `<article>` node to
@@ -1247,7 +1692,7 @@ export function extractReferences(root) {
1247
1692
  continue;
1248
1693
  if (id)
1249
1694
  seenIds.add(id);
1250
- const label = textContent(findOne(ref, 'label')) || undefined;
1695
+ const label = referenceLabel(findOne(ref, 'label'));
1251
1696
  results.push({
1252
1697
  ...(id && { id }),
1253
1698
  ...(label && { label }),
@@ -1269,6 +1714,7 @@ export function parsePmcArticle(articleNode) {
1269
1714
  const articleMeta = findOne(front, 'article-meta');
1270
1715
  const journalMeta = findOne(front, 'journal-meta');
1271
1716
  const body = findOne(articleNode, 'body');
1717
+ const back = findOne(articleNode, 'back');
1272
1718
  const pmcId = extractArticleId(articleMeta, 'pmcid') ?? extractArticleId(articleMeta, 'pmc-uid') ?? '';
1273
1719
  const pmid = extractArticleId(articleMeta, 'pmid');
1274
1720
  const doi = extractArticleId(articleMeta, 'doi');
@@ -1281,6 +1727,7 @@ export function parsePmcArticle(articleNode) {
1281
1727
  const abstract = extractAbstract(articleMeta);
1282
1728
  const keywords = extractKeywords(articleMeta);
1283
1729
  const sections = extractBodySections(body);
1730
+ const backSections = extractBackSections(back);
1284
1731
  const references = extractReferences(articleNode);
1285
1732
  const tables = extractPmcTables(articleNode);
1286
1733
  const assets = extractPmcAssets(articleNode);
@@ -1298,6 +1745,7 @@ export function parsePmcArticle(articleNode) {
1298
1745
  ...(abstract && { abstract }),
1299
1746
  ...(keywords.length > 0 && { keywords }),
1300
1747
  sections,
1748
+ ...(backSections.length > 0 && { backSections }),
1301
1749
  ...(references.length > 0 && { references }),
1302
1750
  ...(tables.length > 0 && { tables }),
1303
1751
  ...(assets.length > 0 && { assets }),