@cyanheads/pubmed-mcp-server 2.10.20 → 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 (65) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +3 -3
  3. package/README.md +7 -6
  4. package/changelog/2.10.x/2.10.21.md +42 -0
  5. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +8 -0
  6. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  7. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +36 -4
  8. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +21 -4
  10. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +287 -127
  12. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
  14. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/format-citations.tool.js +53 -7
  16. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +8 -6
  19. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +0 -1
  22. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  23. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  24. package/dist/services/europe-pmc/api-client.js +4 -3
  25. package/dist/services/europe-pmc/api-client.js.map +1 -1
  26. package/dist/services/fetch-redaction.d.ts +23 -0
  27. package/dist/services/fetch-redaction.d.ts.map +1 -0
  28. package/dist/services/fetch-redaction.js +62 -0
  29. package/dist/services/fetch-redaction.js.map +1 -0
  30. package/dist/services/ncbi/api-client.d.ts +3 -0
  31. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  32. package/dist/services/ncbi/api-client.js +21 -8
  33. package/dist/services/ncbi/api-client.js.map +1 -1
  34. package/dist/services/ncbi/formatting/citation-formatter.d.ts +19 -3
  35. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  36. package/dist/services/ncbi/formatting/citation-formatter.js +224 -46
  37. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  38. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  39. package/dist/services/ncbi/ncbi-service.js +2 -1
  40. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  41. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  42. package/dist/services/ncbi/parsing/article-parser.js +80 -34
  43. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  44. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +8 -7
  45. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  46. package/dist/services/ncbi/parsing/pmc-article-parser.js +257 -57
  47. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  48. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +29 -0
  49. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  50. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +171 -6
  51. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  52. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  53. package/dist/services/ncbi/response-handler.js +3 -0
  54. package/dist/services/ncbi/response-handler.js.map +1 -1
  55. package/dist/services/ncbi/types.d.ts +42 -2
  56. package/dist/services/ncbi/types.d.ts.map +1 -1
  57. package/dist/services/openalex/api-client.d.ts.map +1 -1
  58. package/dist/services/openalex/api-client.js +4 -3
  59. package/dist/services/openalex/api-client.js.map +1 -1
  60. package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
  61. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  62. package/dist/services/unpaywall/unpaywall-service.js +18 -8
  63. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  64. package/package.json +5 -5
  65. package/server.json +3 -3
@@ -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, IDENTIFIER_TAGS, isTextNode, lineTextContent, rawTextContent, selectAlternative, TextAssembler, 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
@@ -327,6 +327,12 @@ function extractAbstract(articleMeta) {
327
327
  * so its `[Box]` and `[End of box]` lines stand alone. `ownLineFirst` reports
328
328
  * either on the first line, which the caller starts below the section's
329
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)
330
336
  */
331
337
  function abstractProse(nodes) {
332
338
  const prose = nodes.filter((node) => {
@@ -334,15 +340,26 @@ function abstractProse(nodes) {
334
340
  return tag !== 'title' && tag !== 'label';
335
341
  });
336
342
  const blocks = flowParts(prose, STATEMENT_BLOCK_TAGS);
343
+ const bullet = blocks.map((block) => !block.ownLineFirst && BULLET_STATEMENT.test(block.text));
337
344
  let text = '';
338
345
  for (const [i, block] of blocks.entries()) {
339
346
  const previous = blocks[i - 1];
340
- if (previous)
341
- text += previous.ownLineLast || block.ownLineFirst ? '\n\n' : ' ';
347
+ if (previous) {
348
+ const blankLine = previous.ownLineLast || block.ownLineFirst || bullet[i - 1] !== bullet[i];
349
+ text += blankLine ? '\n\n' : bullet[i] ? '\n' : ' ';
350
+ }
342
351
  text += block.text;
343
352
  }
344
- return { ownLineFirst: blocks[0]?.ownLineFirst ?? false, text };
353
+ return { ownLineFirst: (blocks[0]?.ownLineFirst ?? false) || (bullet[0] ?? false), text };
345
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/;
346
363
  function extractKeywords(articleMeta) {
347
364
  if (!articleMeta)
348
365
  return [];
@@ -386,13 +403,15 @@ function flushRun(flow) {
386
403
  * prose, {@link STATEMENT_BLOCK_TAGS} for a container whose `<title>` and `<p>`
387
404
  * children are separate statements rather than one continuous sentence.
388
405
  *
389
- * `<tex-math>`, `<alternatives>` and the {@link IDENTIFIER_TAGS} are the elements
390
- * whose text is not the concatenation of their subtree, so this walk defers to
391
- * the shared rule rather than reading their children one at a time: a
392
- * `<tex-math>` contributes its LaTeX document body via `rawTextContent`, an
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
393
412
  * `<alternatives>` re-enters this walk with its single chosen child, which keeps
394
413
  * a `<disp-formula>` found there at block position, and an `<institution-id>`
395
- * in a funding statement contributes nothing. (#135, #208)
414
+ * in a funding statement contributes nothing. (#135, #207, #208)
396
415
  *
397
416
  * The run is a {@link TextAssembler}, so prose follows the separator rules every
398
417
  * other text field does: a `<break/>`, an inline `<fn>` or a `<label>` beside its
@@ -418,6 +437,10 @@ function walkFlow(nodes, flow, blockTags) {
418
437
  flow.run.text(rawTextContent(node));
419
438
  continue;
420
439
  }
440
+ if (isMathTag(tag)) {
441
+ flow.run.text(linearMath(node));
442
+ continue;
443
+ }
421
444
  if (blockTags.has(tag)) {
422
445
  flushRun(flow);
423
446
  const block = renderBlock(node);
@@ -471,9 +494,9 @@ function renderCaption(caption) {
471
494
  * nothing, which is what keeps a `<sec>` that only wraps one from surviving as
472
495
  * an empty "References" heading. (#116, #130)
473
496
  *
474
- * A `<list>` or `<def-list>`, and a delimited `<boxed-text>`, stand on lines of
475
- * their own at both edges; a container read through the default arm takes the
476
- * edges of its first and last blocks. (#202, #210)
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)
477
500
  */
478
501
  function renderBlock(node) {
479
502
  switch (tagNameOf(node)) {
@@ -488,6 +511,8 @@ function renderBlock(node) {
488
511
  return flowBlock(renderList(node, 0), true);
489
512
  case 'def-list':
490
513
  return flowBlock(renderDefList(node, 0), true);
514
+ case 'array':
515
+ return flowBlock(renderArray(node), true);
491
516
  case 'disp-quote':
492
517
  return flowBlock(renderDispQuote(node));
493
518
  case 'boxed-text':
@@ -682,6 +707,28 @@ function renderDefList(defList, depth, withTitle = true) {
682
707
  }
683
708
  return lines.join('\n');
684
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
+ }
685
732
  /** Render a `<disp-quote>`: every line quoted, its `<attrib>` as a trailing attribution line. */
686
733
  function renderDispQuote(quote) {
687
734
  const lines = [];
@@ -793,7 +840,7 @@ function renderPreformat(preformat) {
793
840
  const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
794
841
  /**
795
842
  * Render a `<disp-formula>`: its label, then a `<tex-math>` where the deposit
796
- * carries one (directly or under `<alternatives>`) and the flattened content
843
+ * carries one (directly or under `<alternatives>`) and the content's text
797
844
  * otherwise — usually `<mml:math>`, which is 69 of the 78 formulae in a
798
845
  * 68-record draw against 3 for `<tex-math>`. A graphic-only deposit has no
799
846
  * fallback text and contributes nothing at all rather than a bare label on an
@@ -802,6 +849,9 @@ const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
802
849
  * A `<tex-math>` contributes only the expression between `\begin{document}` and
803
850
  * `\end{document}`; `textContent` applies that rule, so the LaTeX preamble
804
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)
805
855
  */
806
856
  function renderDispFormula(formula) {
807
857
  const texMath = findOne(formula, 'tex-math') ?? findOne(findOne(formula, 'alternatives'), 'tex-math');
@@ -834,8 +884,24 @@ function renderDispFormula(formula) {
834
884
  * content and stay in untitled sections of their own. (#148)
835
885
  */
836
886
  export function extractBodySections(body) {
837
- if (!body)
838
- 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) {
839
905
  const sections = [];
840
906
  let pendingBlocks = [];
841
907
  const flushPending = () => {
@@ -844,14 +910,15 @@ export function extractBodySections(body) {
844
910
  pendingBlocks = [];
845
911
  }
846
912
  };
847
- for (const child of childrenOf(body)) {
848
- if (tagNameOf(child) === 'sec') {
913
+ for (const child of children) {
914
+ const nested = nestedSections(child, back);
915
+ if (nested) {
849
916
  flushPending();
850
- const section = extractSection(child);
851
- if (section)
852
- sections.push(section);
917
+ sections.push(...nested);
853
918
  continue;
854
919
  }
920
+ if (back && SECTION_META_TAGS.has(tagNameOf(child) ?? ''))
921
+ continue;
855
922
  const title = ownBlockTitle(child);
856
923
  if (title) {
857
924
  flushPending();
@@ -863,6 +930,90 @@ export function extractBodySections(body) {
863
930
  flushPending();
864
931
  return sections;
865
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 ────────────────────────────────────────────────────────
866
1017
  /**
867
1018
  * The title a block carries of its own, where the JATS content model puts one:
868
1019
  * a direct `<title>` on a `<def-list>` or `<list>`, the `<caption>` title on a
@@ -902,13 +1053,14 @@ function loneTitledBlock(sec) {
902
1053
  return title ? { node, title } : undefined;
903
1054
  }
904
1055
  /**
905
- * The title a `<sec>` goes by in the output: its own `<title>`, else the one
906
- * {@link loneTitledBlock} lifts. {@link extractSection} titles the section and
907
- * {@link collectSectioned} names the tables and assets inside it from this one
908
- * 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)
909
1061
  */
910
1062
  function secTitle(sec) {
911
- return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title;
1063
+ return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title || fallbackTitle(sec);
912
1064
  }
913
1065
  /** A block whose {@link ownBlockTitle} became its section's title, rendered without it. */
914
1066
  function renderBlockWithoutTitle(node) {
@@ -932,8 +1084,15 @@ function renderBlockWithoutTitle(node) {
932
1084
  *
933
1085
  * An untitled `<sec>` whose only content is a titled block takes that block's
934
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)
935
1094
  */
936
- function extractSection(sec) {
1095
+ function extractSection(sec, back = false) {
937
1096
  const lone = loneTitledBlock(sec);
938
1097
  if (lone) {
939
1098
  const label = textContent(findOne(sec, 'label')) || undefined;
@@ -953,14 +1112,14 @@ function extractSection(sec) {
953
1112
  label ??= textContent(child) || undefined;
954
1113
  continue;
955
1114
  }
956
- if (tag === 'sec') {
957
- const subsection = extractSection(child);
958
- if (subsection)
959
- subsections.push(subsection);
1115
+ const nested = nestedSections(child, back);
1116
+ if (nested) {
1117
+ subsections.push(...nested);
960
1118
  continue;
961
1119
  }
962
- textParts.push(...flowBlocks([child]));
1120
+ textParts.push(...(back && tag === 'fn' ? footnoteParagraphs(child) : flowBlocks([child])));
963
1121
  }
1122
+ title ??= fallbackTitle(sec);
964
1123
  const text = textParts.join('\n\n');
965
1124
  // A section left empty *because* its block content was lifted into `tables[]`
966
1125
  // survives as a heading-only entry: the heading is what places the table for a
@@ -983,10 +1142,10 @@ function extractSection(sec) {
983
1142
  * The walk covers the whole article rather than `<body>` alone: about a quarter
984
1143
  * of real tables sit in `<floats-group>`, `<back>/<sec>`, `<table-wrap-group>`
985
1144
  * or `<app-group>/<app>`, so a body-scoped extractor drops them. Each table
986
- * names the innermost enclosing `<sec>` wherever that sits — a back-matter or
987
- * appendix section counts, and naming it is the reader's only positional cue
988
- * there. Only a table inside no `<sec>` at all, such as a `<floats-group>`
989
- * 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)
990
1149
  *
991
1150
  * Only XHTML `<tr>`/`<td>`/`<th>` bodies are read. **Decision, not an
992
1151
  * oversight:** across a 283-table survey of open-access records, 276 were XHTML,
@@ -1009,19 +1168,21 @@ export function extractPmcTables(root) {
1009
1168
  * positional cue, and an element inside no section at all gets none. A
1010
1169
  * recognized element is not descended into — it parses its own subtree.
1011
1170
  *
1012
- * A section here is whatever {@link extractBodySections} reports as one, under
1013
- * the title it reports: a `<sec>` by {@link secTitle}, and a titled block
1014
- * sitting directly in `<body>` by its own title — so a figure in a titled
1015
- * body-level box names the box, and a `sections` filter on the box keeps it.
1016
- * A `<sec>` inside a `<boxed-text>` is no section: the box flattens it into its
1017
- * text (see {@link renderBoxedText}), so what sits in it names the section the
1018
- * 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)
1019
1179
  *
1020
1180
  * Shared by the table and asset walks so the section-title rule has one
1021
1181
  * definition and cannot drift between them.
1022
1182
  */
1023
- function collectSectioned(node, sectionTitle, out, take, inBox = false) {
1024
- 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';
1025
1186
  for (const child of childrenOf(node)) {
1026
1187
  const tag = tagNameOf(child);
1027
1188
  if (!tag)
@@ -1033,12 +1194,12 @@ function collectSectioned(node, sectionTitle, out, take, inBox = false) {
1033
1194
  }
1034
1195
  const own = inBox
1035
1196
  ? undefined
1036
- : tag === 'sec'
1197
+ : tag === 'sec' || (inBack && BACK_SECTION_TAGS.has(tag))
1037
1198
  ? secTitle(child)
1038
- : inBody
1199
+ : atTopLevel
1039
1200
  ? ownBlockTitle(child)
1040
1201
  : undefined;
1041
- collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text');
1202
+ collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text', inBack || tag === 'back');
1042
1203
  }
1043
1204
  }
1044
1205
  function parseTableWrap(tableWrap, sectionTitle) {
@@ -1184,9 +1345,10 @@ function extractTableRows(table) {
1184
1345
  * supplementary material sit outside `<body>` entirely — `<floats-group>`
1185
1346
  * deposits, `<back>/<sec>` and `<app-group>/<app>` placements, and figures
1186
1347
  * hanging off the abstract in `<front>`. Each asset names the innermost
1187
- * enclosing `<sec>` wherever that sits, an untitled one inheriting its parent's
1188
- * title, so the reader keeps a positional cue; an asset inside no `<sec>` at all
1189
- * 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)
1190
1352
  */
1191
1353
  export function extractPmcAssets(root) {
1192
1354
  if (!root)
@@ -1315,10 +1477,20 @@ const PERSON_TAGS = new Set(['name', 'string-name', 'collab', 'etal']);
1315
1477
  const SUPPLIED_ETAL = 'et al.';
1316
1478
  /** Any character other than whitespace. */
1317
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;
1318
1490
  /**
1319
1491
  * Render one child element of a `<mixed-citation>` or name wrapper. `before` is
1320
- * the citation text written so far, which a `<pub-id>` reads for a literal
1321
- * prefix already naming its type. An `<etal>` reads as its own text, or as
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
1322
1494
  * `et al.` when it is empty — as nearly every deposit leaves it.
1323
1495
  */
1324
1496
  function renderCitationPart(child, tag, before) {
@@ -1354,6 +1526,12 @@ function renderCitationPart(child, tag, before) {
1354
1526
  * - A label this renderer inserts is separated from whatever precedes it. (#115)
1355
1527
  * - A source `.` straight after a supplied `et al.` closes it rather than
1356
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)
1357
1535
  *
1358
1536
  * Gaps the source already fills — `, ` text between names, a newline between a
1359
1537
  * `<string-name>`'s parts — are kept as written, so a fixed separator never
@@ -1370,9 +1548,19 @@ function renderCitationRun(node, wrapper) {
1370
1548
  let text = '';
1371
1549
  /** Whitespace not yet written: the end of the last element's text, then source whitespace after it. */
1372
1550
  let gap = '';
1551
+ /** `gap` begins inside the element written last. */
1552
+ let gapInside = false;
1373
1553
  /** The element written last, while no visible source text has followed it. */
1374
1554
  let previous;
1375
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
+ };
1376
1564
  for (const child of childrenOf(node)) {
1377
1565
  if (isTextNode(child)) {
1378
1566
  let raw = textOf(child);
@@ -1382,23 +1570,32 @@ function renderCitationRun(node, wrapper) {
1382
1570
  }
1383
1571
  if (suppliedEtal)
1384
1572
  raw = raw.replace(/^(\s*)\./, '$1');
1385
- text += gap + raw;
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);
1386
1582
  gap = '';
1387
1583
  previous = undefined;
1388
1584
  suppliedEtal = false;
1389
1585
  continue;
1390
1586
  }
1391
1587
  const tag = tagNameOf(child) ?? '';
1392
- const part = renderCitationPart(child, tag, text);
1588
+ const part = renderCitationPart(child, tag, recent);
1393
1589
  if (!part.text && (wrapper || tag === 'pub-id'))
1394
1590
  continue;
1395
1591
  if (previous && PERSON_TAGS.has(previous) && PERSON_TAGS.has(tag))
1396
- text += ', ';
1592
+ write(', ');
1397
1593
  else
1398
- text += (previous && !gap) || part.labeled ? `${gap} ` : gap;
1594
+ write((previous && !gap) || part.labeled ? `${gap} ` : gap);
1399
1595
  const body = part.text.trimEnd();
1400
- text += body;
1596
+ write(body);
1401
1597
  gap = part.text.slice(body.length);
1598
+ gapInside = gap !== '';
1402
1599
  previous = tag;
1403
1600
  suppliedEtal = part.suppliedEtal;
1404
1601
  }
@@ -1517,6 +1714,7 @@ export function parsePmcArticle(articleNode) {
1517
1714
  const articleMeta = findOne(front, 'article-meta');
1518
1715
  const journalMeta = findOne(front, 'journal-meta');
1519
1716
  const body = findOne(articleNode, 'body');
1717
+ const back = findOne(articleNode, 'back');
1520
1718
  const pmcId = extractArticleId(articleMeta, 'pmcid') ?? extractArticleId(articleMeta, 'pmc-uid') ?? '';
1521
1719
  const pmid = extractArticleId(articleMeta, 'pmid');
1522
1720
  const doi = extractArticleId(articleMeta, 'doi');
@@ -1529,6 +1727,7 @@ export function parsePmcArticle(articleNode) {
1529
1727
  const abstract = extractAbstract(articleMeta);
1530
1728
  const keywords = extractKeywords(articleMeta);
1531
1729
  const sections = extractBodySections(body);
1730
+ const backSections = extractBackSections(back);
1532
1731
  const references = extractReferences(articleNode);
1533
1732
  const tables = extractPmcTables(articleNode);
1534
1733
  const assets = extractPmcAssets(articleNode);
@@ -1546,6 +1745,7 @@ export function parsePmcArticle(articleNode) {
1546
1745
  ...(abstract && { abstract }),
1547
1746
  ...(keywords.length > 0 && { keywords }),
1548
1747
  sections,
1748
+ ...(backSections.length > 0 && { backSections }),
1549
1749
  ...(references.length > 0 && { references }),
1550
1750
  ...(tables.length > 0 && { tables }),
1551
1751
  ...(assets.length > 0 && { assets }),