@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.
- package/AGENTS.md +3 -3
- package/CLAUDE.md +3 -3
- package/README.md +7 -6
- package/changelog/2.10.x/2.10.21.md +42 -0
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +8 -0
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +36 -4
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +21 -4
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +287 -127
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +53 -7
- package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +8 -6
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +0 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.js +4 -3
- package/dist/services/europe-pmc/api-client.js.map +1 -1
- package/dist/services/fetch-redaction.d.ts +23 -0
- package/dist/services/fetch-redaction.d.ts.map +1 -0
- package/dist/services/fetch-redaction.js +62 -0
- package/dist/services/fetch-redaction.js.map +1 -0
- package/dist/services/ncbi/api-client.d.ts +3 -0
- package/dist/services/ncbi/api-client.d.ts.map +1 -1
- package/dist/services/ncbi/api-client.js +21 -8
- package/dist/services/ncbi/api-client.js.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.d.ts +19 -3
- package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.js +224 -46
- package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +2 -1
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.js +80 -34
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +8 -7
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +257 -57
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +29 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +171 -6
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +3 -0
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +42 -2
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.d.ts.map +1 -1
- package/dist/services/openalex/api-client.js +4 -3
- package/dist/services/openalex/api-client.js.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
- package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.js +18 -8
- package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
- package/package.json +5 -5
- 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
|
-
|
|
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}
|
|
390
|
-
* whose text is not the concatenation of their subtree, so
|
|
391
|
-
* the shared rule rather than reading their children one at
|
|
392
|
-
* `<tex-math>` contributes its LaTeX document body via
|
|
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 `<
|
|
475
|
-
* their own at both edges; a container read through the default arm
|
|
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
|
|
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
|
-
|
|
838
|
-
|
|
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
|
|
848
|
-
|
|
913
|
+
for (const child of children) {
|
|
914
|
+
const nested = nestedSections(child, back);
|
|
915
|
+
if (nested) {
|
|
849
916
|
flushPending();
|
|
850
|
-
|
|
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>`
|
|
906
|
-
*
|
|
907
|
-
* {@link
|
|
908
|
-
*
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
|
987
|
-
*
|
|
988
|
-
* there. Only a table inside no
|
|
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}
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* box
|
|
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
|
|
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
|
-
:
|
|
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
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1592
|
+
write(', ');
|
|
1397
1593
|
else
|
|
1398
|
-
|
|
1594
|
+
write((previous && !gap) || part.labeled ? `${gap} ` : gap);
|
|
1399
1595
|
const body = part.text.trimEnd();
|
|
1400
|
-
|
|
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 }),
|