@cyanheads/pubmed-mcp-server 2.10.20 → 2.10.22
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/Dockerfile +3 -3
- package/README.md +14 -11
- package/changelog/2.10.x/2.10.21.md +42 -0
- package/changelog/2.10.x/2.10.22.md +44 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_budget.d.ts +35 -6
- package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_budget.js +26 -3
- package/dist/mcp-server/tools/definitions/_budget.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_notices.d.ts +33 -0
- package/dist/mcp-server/tools/definitions/_notices.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_notices.js +35 -0
- package/dist/mcp-server/tools/definitions/_notices.js.map +1 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts +14 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_schemas.js +9 -0
- package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +11 -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 +64 -19
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +22 -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 +386 -167
- 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 +41 -8
- 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/lookup-mesh.tool.d.ts +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +66 -76
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +6 -5
- 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 +11 -18
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -4
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +9 -8
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +4 -4
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +12 -8
- package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +1 -1
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +1 -1
- package/dist/services/error-contracts.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/europe-pmc/europe-pmc-service.d.ts +31 -11
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +122 -47
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +5 -1
- package/dist/services/europe-pmc/types.d.ts.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 +410 -138
- package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +22 -0
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +115 -18
- 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 +89 -36
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +13 -8
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +326 -72
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +42 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +275 -12
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.d.ts +10 -0
- package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.js +19 -0
- package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts +0 -11
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +24 -33
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +72 -32
- 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 +7 -7
- 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, CLOSING_PUNCTUATION, 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
|
|
@@ -125,7 +125,11 @@ function isAuthorContrib(contrib) {
|
|
|
125
125
|
const contribType = attrOf(contrib, 'contrib-type');
|
|
126
126
|
return !contribType || contribType === 'author';
|
|
127
127
|
}
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Extract authors from a single `<contrib-group>` node. Non-author contributors
|
|
130
|
+
* are skipped. A name part the deposit lacks is omitted; a `<suffix>` is kept
|
|
131
|
+
* verbatim (`IV`, never rewritten to `4th`). (#226)
|
|
132
|
+
*/
|
|
129
133
|
export function extractJatsAuthors(contribGroup) {
|
|
130
134
|
if (!contribGroup)
|
|
131
135
|
return [];
|
|
@@ -144,9 +148,11 @@ export function extractJatsAuthors(contribGroup) {
|
|
|
144
148
|
if (nameNode) {
|
|
145
149
|
const lastName = textContent(findOne(nameNode, 'surname')) || undefined;
|
|
146
150
|
const givenNames = textContent(findOne(nameNode, 'given-names')) || undefined;
|
|
151
|
+
const suffix = textContent(findOne(nameNode, 'suffix')) || undefined;
|
|
147
152
|
authors.push({
|
|
148
153
|
...(lastName && { lastName }),
|
|
149
154
|
...(givenNames && { givenNames }),
|
|
155
|
+
...(suffix && { suffix }),
|
|
150
156
|
});
|
|
151
157
|
}
|
|
152
158
|
}
|
|
@@ -327,6 +333,12 @@ function extractAbstract(articleMeta) {
|
|
|
327
333
|
* so its `[Box]` and `[End of box]` lines stand alone. `ownLineFirst` reports
|
|
328
334
|
* either on the first line, which the caller starts below the section's
|
|
329
335
|
* heading (PMC10869376). (#202, #210)
|
|
336
|
+
*
|
|
337
|
+
* A run of {@link BULLET_STATEMENT} paragraphs — a "Key points" section a
|
|
338
|
+
* deposit writes as `<p>• …</p>` rather than as a `<list>` — lays out the same
|
|
339
|
+
* way: one paragraph per line with its bullet kept, set apart from the
|
|
340
|
+
* statements around it by a blank line, and the first below the heading
|
|
341
|
+
* (PMC10799778). (#218)
|
|
330
342
|
*/
|
|
331
343
|
function abstractProse(nodes) {
|
|
332
344
|
const prose = nodes.filter((node) => {
|
|
@@ -334,15 +346,26 @@ function abstractProse(nodes) {
|
|
|
334
346
|
return tag !== 'title' && tag !== 'label';
|
|
335
347
|
});
|
|
336
348
|
const blocks = flowParts(prose, STATEMENT_BLOCK_TAGS);
|
|
349
|
+
const bullet = blocks.map((block) => !block.ownLineFirst && BULLET_STATEMENT.test(block.text));
|
|
337
350
|
let text = '';
|
|
338
351
|
for (const [i, block] of blocks.entries()) {
|
|
339
352
|
const previous = blocks[i - 1];
|
|
340
|
-
if (previous)
|
|
341
|
-
|
|
353
|
+
if (previous) {
|
|
354
|
+
const blankLine = previous.ownLineLast || block.ownLineFirst || bullet[i - 1] !== bullet[i];
|
|
355
|
+
text += blankLine ? '\n\n' : bullet[i] ? '\n' : ' ';
|
|
356
|
+
}
|
|
342
357
|
text += block.text;
|
|
343
358
|
}
|
|
344
|
-
return { ownLineFirst: blocks[0]?.ownLineFirst ?? false, text };
|
|
359
|
+
return { ownLineFirst: (blocks[0]?.ownLineFirst ?? false) || (bullet[0] ?? false), text };
|
|
345
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* A statement deposited as a bullet point: its text opens with a bullet
|
|
363
|
+
* character — `•` U+2022, `‣` U+2023, `⁃` U+2043 or `◦` U+25E6 — and carries
|
|
364
|
+
* text after it. Characters that open a paragraph with another meaning stay
|
|
365
|
+
* out: `·` and `∙` (multiplication), `*` and daggers (footnote marks), hyphens
|
|
366
|
+
* and dashes (minus signs, ranges), `■` (an end-of-proof mark). (#218)
|
|
367
|
+
*/
|
|
368
|
+
const BULLET_STATEMENT = /^[•‣⁃◦]\s*\S/;
|
|
346
369
|
function extractKeywords(articleMeta) {
|
|
347
370
|
if (!articleMeta)
|
|
348
371
|
return [];
|
|
@@ -386,18 +409,23 @@ function flushRun(flow) {
|
|
|
386
409
|
* prose, {@link STATEMENT_BLOCK_TAGS} for a container whose `<title>` and `<p>`
|
|
387
410
|
* children are separate statements rather than one continuous sentence.
|
|
388
411
|
*
|
|
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
|
|
412
|
+
* `<tex-math>`, MathML `<math>`, `<alternatives>` and the {@link IDENTIFIER_TAGS}
|
|
413
|
+
* are the elements whose text is not the concatenation of their subtree, so
|
|
414
|
+
* this walk defers to the shared rule rather than reading their children one at
|
|
415
|
+
* a time: a `<tex-math>` contributes its LaTeX document body via
|
|
416
|
+
* `rawTextContent`, a `<math>` its TeX-style linear form via `linearMath`
|
|
417
|
+
* wherever it sits — an inline formula or directly in a `<p>` — an
|
|
393
418
|
* `<alternatives>` re-enters this walk with its single chosen child, which keeps
|
|
394
419
|
* a `<disp-formula>` found there at block position, and an `<institution-id>`
|
|
395
|
-
* in a funding statement contributes nothing. (#135, #208)
|
|
420
|
+
* in a funding statement contributes nothing. (#135, #207, #208)
|
|
396
421
|
*
|
|
397
422
|
* The run is a {@link TextAssembler}, so prose follows the separator rules every
|
|
398
423
|
* other text field does: a `<break/>`, an inline `<fn>` or a `<label>` beside its
|
|
399
|
-
* text reads as one space rather than fusing the words on either side,
|
|
400
|
-
*
|
|
424
|
+
* text reads as one space rather than fusing the words on either side, a run of
|
|
425
|
+
* `<xref>` markers with nothing between them reads `[1,2,3,4]`, and a line break
|
|
426
|
+
* between a formula — or any element — and the period after it is dropped:
|
|
427
|
+
* `L<0.`, not `L<0 .`. A `<tex-math>` or `<math>` is opened and closed on the
|
|
428
|
+
* run like any element, so that rule sees it. (#185, #197, #225)
|
|
401
429
|
*/
|
|
402
430
|
function walkFlow(nodes, flow, blockTags) {
|
|
403
431
|
for (const node of nodes) {
|
|
@@ -414,8 +442,10 @@ function walkFlow(nodes, flow, blockTags) {
|
|
|
414
442
|
walkFlow([chosen], flow, blockTags);
|
|
415
443
|
continue;
|
|
416
444
|
}
|
|
417
|
-
if (tag === 'tex-math') {
|
|
418
|
-
flow.run.
|
|
445
|
+
if (tag === 'tex-math' || isMathTag(tag)) {
|
|
446
|
+
flow.run.open(tag);
|
|
447
|
+
flow.run.text(tag === 'tex-math' ? rawTextContent(node) : linearMath(node));
|
|
448
|
+
flow.run.close(tag);
|
|
419
449
|
continue;
|
|
420
450
|
}
|
|
421
451
|
if (blockTags.has(tag)) {
|
|
@@ -471,9 +501,9 @@ function renderCaption(caption) {
|
|
|
471
501
|
* nothing, which is what keeps a `<sec>` that only wraps one from surviving as
|
|
472
502
|
* an empty "References" heading. (#116, #130)
|
|
473
503
|
*
|
|
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)
|
|
504
|
+
* A `<list>`, `<def-list>` or `<array>`, and a delimited `<boxed-text>`, stand on
|
|
505
|
+
* lines of their own at both edges; a container read through the default arm
|
|
506
|
+
* takes the edges of its first and last blocks. (#202, #206, #210)
|
|
477
507
|
*/
|
|
478
508
|
function renderBlock(node) {
|
|
479
509
|
switch (tagNameOf(node)) {
|
|
@@ -488,6 +518,8 @@ function renderBlock(node) {
|
|
|
488
518
|
return flowBlock(renderList(node, 0), true);
|
|
489
519
|
case 'def-list':
|
|
490
520
|
return flowBlock(renderDefList(node, 0), true);
|
|
521
|
+
case 'array':
|
|
522
|
+
return flowBlock(renderArray(node), true);
|
|
491
523
|
case 'disp-quote':
|
|
492
524
|
return flowBlock(renderDispQuote(node));
|
|
493
525
|
case 'boxed-text':
|
|
@@ -545,12 +577,28 @@ function alternativePointer(alternatives) {
|
|
|
545
577
|
* left in the section text and the `label` reported in `assets[]` cannot
|
|
546
578
|
* disagree — the tool layer removes the marker by rebuilding it from that label,
|
|
547
579
|
* so a label resolved one way here and another way there strands the marker in
|
|
548
|
-
* the prose under `includeAssets: false`.
|
|
580
|
+
* the prose under `includeAssets: false`. That is also why the delimiter is
|
|
581
|
+
* stripped here rather than at render (see {@link floatLabel}). (#130, #240)
|
|
549
582
|
*/
|
|
550
583
|
function assetLabel(node) {
|
|
551
|
-
return (
|
|
552
|
-
|
|
553
|
-
|
|
584
|
+
return floatLabel(findOne(node, 'label')) ?? floatLabel(findOne(assetPointer(node), 'label'));
|
|
585
|
+
}
|
|
586
|
+
/** Delimiters a deposit may print after a table or asset label. */
|
|
587
|
+
const LABEL_DELIMITERS = new Set(['.', '|', ':']);
|
|
588
|
+
/**
|
|
589
|
+
* A table or asset `<label>`'s text without the one delimiter some deposits
|
|
590
|
+
* print after it — `Table 1.`, `Figure 1 |`, `Figure 1:` read `Table 1`,
|
|
591
|
+
* `Figure 1`. Every surface that names the item puts its own punctuation after
|
|
592
|
+
* the label: a heading's ` — ` before the caption, a list's `, `, a notice's
|
|
593
|
+
* closing period, which read `Table 2..` with the delimiter kept. Only the last
|
|
594
|
+
* character goes, so an internal one stays (`Fig. 1.` reads `Fig. 1`). A label
|
|
595
|
+
* that is nothing but a delimiter reads as no label. Reference labels are not
|
|
596
|
+
* read here and keep their printed form (#209). (#240)
|
|
597
|
+
*/
|
|
598
|
+
function floatLabel(label) {
|
|
599
|
+
const text = textContent(label);
|
|
600
|
+
const stripped = LABEL_DELIMITERS.has(text.at(-1) ?? '') ? text.slice(0, -1).trimEnd() : text;
|
|
601
|
+
return stripped || undefined;
|
|
554
602
|
}
|
|
555
603
|
/**
|
|
556
604
|
* The marker a lifted asset leaves at the position it occupied. Prose refers to
|
|
@@ -682,6 +730,28 @@ function renderDefList(defList, depth, withTitle = true) {
|
|
|
682
730
|
}
|
|
683
731
|
return lines.join('\n');
|
|
684
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Render an `<array>` — tabular material with no caption of its own, such as a
|
|
735
|
+
* glossary's grid of abbreviations — one row per line, its cells joined the way
|
|
736
|
+
* {@link renderDefList} joins a term to its definition:
|
|
737
|
+
* `- RSNA — Radiological Society of North America`. Read as flow, every cell ran
|
|
738
|
+
* into the next (`RSNARadiological Society of North AmericaRT-PCR…`). (#206)
|
|
739
|
+
*/
|
|
740
|
+
function renderArray(array) {
|
|
741
|
+
const lines = [];
|
|
742
|
+
for (const row of findAllDescendants(array, 'tr')) {
|
|
743
|
+
const cells = childrenOf(row)
|
|
744
|
+
.filter((cell) => {
|
|
745
|
+
const tag = tagNameOf(cell);
|
|
746
|
+
return tag === 'td' || tag === 'th';
|
|
747
|
+
})
|
|
748
|
+
.map((cell) => textContent(cell))
|
|
749
|
+
.filter(Boolean);
|
|
750
|
+
if (cells.length > 0)
|
|
751
|
+
lines.push(`- ${cells.join(' — ')}`);
|
|
752
|
+
}
|
|
753
|
+
return lines.join('\n');
|
|
754
|
+
}
|
|
685
755
|
/** Render a `<disp-quote>`: every line quoted, its `<attrib>` as a trailing attribution line. */
|
|
686
756
|
function renderDispQuote(quote) {
|
|
687
757
|
const lines = [];
|
|
@@ -723,7 +793,7 @@ function renderBoxedText(boxedText, { withLabel = true, withTitle = true } = {})
|
|
|
723
793
|
blocks.push(...flowBlocks(parts, STATEMENT_BLOCK_TAGS));
|
|
724
794
|
}
|
|
725
795
|
else if (withLabel || tag !== 'label')
|
|
726
|
-
blocks.push(...
|
|
796
|
+
blocks.push(...sectionBlocks(child));
|
|
727
797
|
}
|
|
728
798
|
return blocks.join('\n\n');
|
|
729
799
|
}
|
|
@@ -773,7 +843,7 @@ function flattenSec(sec) {
|
|
|
773
843
|
if (tag === 'sec')
|
|
774
844
|
nested.push(...flattenSec(child));
|
|
775
845
|
else
|
|
776
|
-
blocks.push(...
|
|
846
|
+
blocks.push(...sectionBlocks(child));
|
|
777
847
|
}
|
|
778
848
|
const head = [heading, blocks.join('\n\n')].filter(Boolean).join('\n');
|
|
779
849
|
return [...(head ? [head] : []), ...nested];
|
|
@@ -793,7 +863,7 @@ function renderPreformat(preformat) {
|
|
|
793
863
|
const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
|
|
794
864
|
/**
|
|
795
865
|
* Render a `<disp-formula>`: its label, then a `<tex-math>` where the deposit
|
|
796
|
-
* carries one (directly or under `<alternatives>`) and the
|
|
866
|
+
* carries one (directly or under `<alternatives>`) and the content's text
|
|
797
867
|
* otherwise — usually `<mml:math>`, which is 69 of the 78 formulae in a
|
|
798
868
|
* 68-record draw against 3 for `<tex-math>`. A graphic-only deposit has no
|
|
799
869
|
* fallback text and contributes nothing at all rather than a bare label on an
|
|
@@ -802,6 +872,9 @@ const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
|
|
|
802
872
|
* A `<tex-math>` contributes only the expression between `\begin{document}` and
|
|
803
873
|
* `\end{document}`; `textContent` applies that rule, so the LaTeX preamble
|
|
804
874
|
* publishers wrap around every formula never reaches the rendered line. (#135)
|
|
875
|
+
* A MathML `<math>` reads in the TeX-style linear form `textContent` gives it
|
|
876
|
+
* (`(L & M \\ M^{T} & N)<0`), so a formula keeps its scripts and matrix cells
|
|
877
|
+
* whichever notation the deposit carries. (#207)
|
|
805
878
|
*/
|
|
806
879
|
function renderDispFormula(formula) {
|
|
807
880
|
const texMath = findOne(formula, 'tex-math') ?? findOne(findOne(formula, 'alternatives'), 'tex-math');
|
|
@@ -834,8 +907,24 @@ function renderDispFormula(formula) {
|
|
|
834
907
|
* content and stay in untitled sections of their own. (#148)
|
|
835
908
|
*/
|
|
836
909
|
export function extractBodySections(body) {
|
|
837
|
-
|
|
838
|
-
|
|
910
|
+
return body ? extractSections(childrenOf(body), false) : [];
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Extract back-matter sections from a `<back>` node: every child but
|
|
914
|
+
* `<ref-list>` — which `references[]` carries — and `<back>`'s own label and
|
|
915
|
+
* title, in source order. Each back-matter element is read as a `<sec>` is (see
|
|
916
|
+
* {@link BACK_SECTION_TAGS}), taking the title its element names when it carries
|
|
917
|
+
* neither a title nor a label (see {@link BACK_MATTER_TITLES}). (#206)
|
|
918
|
+
*/
|
|
919
|
+
function extractBackSections(back) {
|
|
920
|
+
return back ? extractSections(childrenOf(back), true) : [];
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Read the children of `<body>` — or, with `back`, of `<back>` — into sections,
|
|
924
|
+
* in document order. {@link extractBodySections} states the rules; back matter
|
|
925
|
+
* adds its own section elements and skips `<back>`'s label and title.
|
|
926
|
+
*/
|
|
927
|
+
function extractSections(children, back) {
|
|
839
928
|
const sections = [];
|
|
840
929
|
let pendingBlocks = [];
|
|
841
930
|
const flushPending = () => {
|
|
@@ -844,25 +933,135 @@ export function extractBodySections(body) {
|
|
|
844
933
|
pendingBlocks = [];
|
|
845
934
|
}
|
|
846
935
|
};
|
|
847
|
-
for (const child of
|
|
848
|
-
|
|
936
|
+
for (const child of children) {
|
|
937
|
+
const nested = nestedSections(child, back);
|
|
938
|
+
if (nested) {
|
|
849
939
|
flushPending();
|
|
850
|
-
|
|
851
|
-
if (section)
|
|
852
|
-
sections.push(section);
|
|
940
|
+
sections.push(...nested);
|
|
853
941
|
continue;
|
|
854
942
|
}
|
|
943
|
+
if (back && SECTION_META_TAGS.has(tagNameOf(child) ?? ''))
|
|
944
|
+
continue;
|
|
855
945
|
const title = ownBlockTitle(child);
|
|
856
946
|
if (title) {
|
|
857
947
|
flushPending();
|
|
858
948
|
sections.push({ title, text: renderBlockWithoutTitle(child) });
|
|
859
949
|
continue;
|
|
860
950
|
}
|
|
861
|
-
pendingBlocks.push(...
|
|
951
|
+
pendingBlocks.push(...sectionBlocks(child));
|
|
862
952
|
}
|
|
863
953
|
flushPending();
|
|
864
954
|
return sections;
|
|
865
955
|
}
|
|
956
|
+
// ─── Back Matter ────────────────────────────────────────────────────────────
|
|
957
|
+
/**
|
|
958
|
+
* Back-matter elements read as sections, each mapped to the title it goes by
|
|
959
|
+
* when it carries neither a `<title>` nor a `<label>`. Most `<fn-group>`s carry
|
|
960
|
+
* neither, and without a title a section renders as `untitled section` where no
|
|
961
|
+
* `sections` filter term can reach it. The title names the element, not a fact
|
|
962
|
+
* about the article; Europe PMC titles the same `<fn-group>` `Footnotes` when it
|
|
963
|
+
* moves back matter into `<body>`. A labelled element takes no fallback, so an
|
|
964
|
+
* `Appendix A` label never reads `Appendix Appendix A`. (#206)
|
|
965
|
+
*/
|
|
966
|
+
const BACK_MATTER_TITLES = new Map([
|
|
967
|
+
['ack', 'Acknowledgments'],
|
|
968
|
+
['app', 'Appendix'],
|
|
969
|
+
['bio', 'Biography'],
|
|
970
|
+
['fn-group', 'Footnotes'],
|
|
971
|
+
['glossary', 'Glossary'],
|
|
972
|
+
['notes', 'Notes'],
|
|
973
|
+
]);
|
|
974
|
+
/**
|
|
975
|
+
* Elements that form a section of their own in back matter: `<sec>`,
|
|
976
|
+
* `<app-group>`, and each element {@link BACK_MATTER_TITLES} names. One nested in
|
|
977
|
+
* another is a subsection, as a `<sec>` in a `<sec>` is, so a Springer
|
|
978
|
+
* `Declarations` `<notes>` keeps its inner notes' titles out of its text. In
|
|
979
|
+
* `<body>` only a `<sec>` forms one: a `<notes>` or `<fn-group>` closing a body
|
|
980
|
+
* `<sec>` stays that section's text, as it always read — a `<fn-group>` one
|
|
981
|
+
* paragraph per footnote, as in back matter (see {@link sectionBlocks}). (#206,
|
|
982
|
+
* #229)
|
|
983
|
+
*/
|
|
984
|
+
const BACK_SECTION_TAGS = new Set([
|
|
985
|
+
'sec',
|
|
986
|
+
'app-group',
|
|
987
|
+
...BACK_MATTER_TITLES.keys(),
|
|
988
|
+
]);
|
|
989
|
+
/**
|
|
990
|
+
* True for an `<app-group>` with neither a title nor a label. It has no heading
|
|
991
|
+
* to give a section, so its children are read as if they sat in `<back>`: its
|
|
992
|
+
* `<app>`s stand as sections in its place, and the paragraphs, lists or figures
|
|
993
|
+
* JATS lets it carry ahead of them form an untitled section rather than being
|
|
994
|
+
* dropped. (#206)
|
|
995
|
+
*/
|
|
996
|
+
function isHeadlessAppGroup(node) {
|
|
997
|
+
return (tagNameOf(node) === 'app-group' &&
|
|
998
|
+
!textContent(findOne(node, 'title')) &&
|
|
999
|
+
!textContent(findOne(node, 'label')));
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The sections `node` forms where it sits among a section's children, or among
|
|
1003
|
+
* `<body>`'s or `<back>`'s: a `<sec>` anywhere, and in back matter each element
|
|
1004
|
+
* of {@link BACK_SECTION_TAGS} — a headless `<app-group>` by way of its
|
|
1005
|
+
* children (see {@link isHeadlessAppGroup}). Undefined for anything that is
|
|
1006
|
+
* section content instead. (#206)
|
|
1007
|
+
*/
|
|
1008
|
+
function nestedSections(node, back) {
|
|
1009
|
+
const tag = tagNameOf(node) ?? '';
|
|
1010
|
+
if (tag !== 'sec' && !(back && BACK_SECTION_TAGS.has(tag)))
|
|
1011
|
+
return;
|
|
1012
|
+
if (isHeadlessAppGroup(node))
|
|
1013
|
+
return extractSections(childrenOf(node), back);
|
|
1014
|
+
const section = extractSection(node, back);
|
|
1015
|
+
return section ? [section] : [];
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* The title a back-matter element with neither a `<title>` nor a `<label>` goes
|
|
1019
|
+
* by (see {@link BACK_MATTER_TITLES}); undefined for every other element, a
|
|
1020
|
+
* `<sec>` included. (#206)
|
|
1021
|
+
*/
|
|
1022
|
+
function fallbackTitle(node) {
|
|
1023
|
+
if (textContent(findOne(node, 'label')))
|
|
1024
|
+
return;
|
|
1025
|
+
return BACK_MATTER_TITLES.get(tagNameOf(node) ?? '');
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The paragraphs of a `<fn>` that stands as a block — in back matter, or in a
|
|
1029
|
+
* body `<sec>`, `<boxed-text>` or `<fn-group>` (see {@link sectionBlocks}): each
|
|
1030
|
+
* `<p>`, and each block one holds, on its own, with the footnote's `<label>`
|
|
1031
|
+
* leading the first. Read as one run, a footnote whose heading sits in a `<p>`
|
|
1032
|
+
* of its own fused that heading into the statement after it (`Competing
|
|
1033
|
+
* interests The authors declare…`), the fusion a titled `<notes>` is kept from.
|
|
1034
|
+
* (#206, #229)
|
|
1035
|
+
*/
|
|
1036
|
+
function footnoteParagraphs(fn) {
|
|
1037
|
+
const label = textContent(findOne(fn, 'label'));
|
|
1038
|
+
const [first, ...rest] = flowBlocks(childrenOf(fn).filter((child) => tagNameOf(child) !== 'label'), STATEMENT_BLOCK_TAGS);
|
|
1039
|
+
if (first === undefined)
|
|
1040
|
+
return label ? [label] : [];
|
|
1041
|
+
return [label ? `${label} ${first}` : first, ...rest];
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* The text blocks one child of a section contributes — of a `<sec>`, of
|
|
1045
|
+
* `<body>`, of a `<boxed-text>` or of a `<sec>` one flattens. A `<fn>` reads as
|
|
1046
|
+
* {@link footnoteParagraphs} reads it, and so does each `<fn>` of a
|
|
1047
|
+
* `<fn-group>`, the group's own `<title>` a paragraph ahead of them; anything
|
|
1048
|
+
* else is flow text. Europe PMC moves back matter into `<body>` as
|
|
1049
|
+
* `<sec sec-type="fn-group"><fn-group>…`, and read as flow text there a group's
|
|
1050
|
+
* footnotes ran into one paragraph where the PMC tier, reading the same group
|
|
1051
|
+
* from `<back>`, returned one per statement. A `<fn>` inside a `<p>` is no child
|
|
1052
|
+
* here and still reads inside its sentence. (#229)
|
|
1053
|
+
*/
|
|
1054
|
+
function sectionBlocks(child) {
|
|
1055
|
+
switch (tagNameOf(child)) {
|
|
1056
|
+
case 'fn':
|
|
1057
|
+
return footnoteParagraphs(child);
|
|
1058
|
+
case 'fn-group':
|
|
1059
|
+
return childrenOf(child).flatMap(sectionBlocks);
|
|
1060
|
+
default:
|
|
1061
|
+
return flowBlocks([child]);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
// ─── Section Reading ────────────────────────────────────────────────────────
|
|
866
1065
|
/**
|
|
867
1066
|
* The title a block carries of its own, where the JATS content model puts one:
|
|
868
1067
|
* a direct `<title>` on a `<def-list>` or `<list>`, the `<caption>` title on a
|
|
@@ -902,13 +1101,14 @@ function loneTitledBlock(sec) {
|
|
|
902
1101
|
return title ? { node, title } : undefined;
|
|
903
1102
|
}
|
|
904
1103
|
/**
|
|
905
|
-
* The title a `<sec>`
|
|
906
|
-
*
|
|
907
|
-
* {@link
|
|
908
|
-
*
|
|
1104
|
+
* The title a `<sec>` — or a back-matter element read as one — goes by in the
|
|
1105
|
+
* output: its own `<title>`, else the one {@link loneTitledBlock} lifts, else
|
|
1106
|
+
* the one {@link fallbackTitle} names. {@link extractSection} titles the section
|
|
1107
|
+
* and {@link collectSectioned} names the tables and assets inside it from this
|
|
1108
|
+
* one rule, so a `sections` filter keeps them together. (#169, #206)
|
|
909
1109
|
*/
|
|
910
1110
|
function secTitle(sec) {
|
|
911
|
-
return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title;
|
|
1111
|
+
return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title || fallbackTitle(sec);
|
|
912
1112
|
}
|
|
913
1113
|
/** A block whose {@link ownBlockTitle} became its section's title, rendered without it. */
|
|
914
1114
|
function renderBlockWithoutTitle(node) {
|
|
@@ -932,8 +1132,16 @@ function renderBlockWithoutTitle(node) {
|
|
|
932
1132
|
*
|
|
933
1133
|
* An untitled `<sec>` whose only content is a titled block takes that block's
|
|
934
1134
|
* title, and the block renders without it (see {@link loneTitledBlock}). (#169)
|
|
1135
|
+
*
|
|
1136
|
+
* With `back`, `sec` may be any back-matter element {@link nestedSections}
|
|
1137
|
+
* reads as a section: the back-matter elements among its children are
|
|
1138
|
+
* subsections too, and with neither a title nor a label it takes the title its
|
|
1139
|
+
* element names. Each child contributes its own block, so every `<fn>` of a
|
|
1140
|
+
* `<fn-group>` is a paragraph of its own, and each `<p>` inside one too — in
|
|
1141
|
+
* back matter and, for a `<fn-group>` that stays a body section's text, in
|
|
1142
|
+
* `<body>` alike (see {@link sectionBlocks}). (#206, #229)
|
|
935
1143
|
*/
|
|
936
|
-
function extractSection(sec) {
|
|
1144
|
+
function extractSection(sec, back = false) {
|
|
937
1145
|
const lone = loneTitledBlock(sec);
|
|
938
1146
|
if (lone) {
|
|
939
1147
|
const label = textContent(findOne(sec, 'label')) || undefined;
|
|
@@ -953,14 +1161,14 @@ function extractSection(sec) {
|
|
|
953
1161
|
label ??= textContent(child) || undefined;
|
|
954
1162
|
continue;
|
|
955
1163
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
subsections.push(subsection);
|
|
1164
|
+
const nested = nestedSections(child, back);
|
|
1165
|
+
if (nested) {
|
|
1166
|
+
subsections.push(...nested);
|
|
960
1167
|
continue;
|
|
961
1168
|
}
|
|
962
|
-
textParts.push(...
|
|
1169
|
+
textParts.push(...sectionBlocks(child));
|
|
963
1170
|
}
|
|
1171
|
+
title ??= fallbackTitle(sec);
|
|
964
1172
|
const text = textParts.join('\n\n');
|
|
965
1173
|
// A section left empty *because* its block content was lifted into `tables[]`
|
|
966
1174
|
// survives as a heading-only entry: the heading is what places the table for a
|
|
@@ -983,10 +1191,10 @@ function extractSection(sec) {
|
|
|
983
1191
|
* The walk covers the whole article rather than `<body>` alone: about a quarter
|
|
984
1192
|
* of real tables sit in `<floats-group>`, `<back>/<sec>`, `<table-wrap-group>`
|
|
985
1193
|
* 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.
|
|
1194
|
+
* names the innermost enclosing section wherever that sits — a back-matter
|
|
1195
|
+
* `<sec>`, `<app>`, `<notes>` or `<fn-group>` counts, and naming it is the
|
|
1196
|
+
* reader's only positional cue there. Only a table inside no section at all,
|
|
1197
|
+
* such as a `<floats-group>` deposit, carries no section name. (#206)
|
|
990
1198
|
*
|
|
991
1199
|
* Only XHTML `<tr>`/`<td>`/`<th>` bodies are read. **Decision, not an
|
|
992
1200
|
* oversight:** across a 283-table survey of open-access records, 276 were XHTML,
|
|
@@ -1009,19 +1217,21 @@ export function extractPmcTables(root) {
|
|
|
1009
1217
|
* positional cue, and an element inside no section at all gets none. A
|
|
1010
1218
|
* recognized element is not descended into — it parses its own subtree.
|
|
1011
1219
|
*
|
|
1012
|
-
* A section here is whatever {@link extractBodySections}
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* box
|
|
1220
|
+
* A section here is whatever {@link extractBodySections} and
|
|
1221
|
+
* {@link extractBackSections} report as one, under the title they report: a
|
|
1222
|
+
* `<sec>` — and inside `<back>`, each back-matter element read as one — by
|
|
1223
|
+
* {@link secTitle}, and a titled block sitting directly in `<body>` or `<back>`
|
|
1224
|
+
* by its own title — so a figure in a titled body-level box names the box, and
|
|
1225
|
+
* a `sections` filter on the box keeps it. A `<sec>` inside a `<boxed-text>` is
|
|
1226
|
+
* no section: the box flattens it into its text (see {@link renderBoxedText}),
|
|
1227
|
+
* so what sits in it names the section the box belongs to. (#169, #206)
|
|
1019
1228
|
*
|
|
1020
1229
|
* Shared by the table and asset walks so the section-title rule has one
|
|
1021
1230
|
* definition and cannot drift between them.
|
|
1022
1231
|
*/
|
|
1023
|
-
function collectSectioned(node, sectionTitle, out, take, inBox = false) {
|
|
1024
|
-
const
|
|
1232
|
+
function collectSectioned(node, sectionTitle, out, take, inBox = false, inBack = false) {
|
|
1233
|
+
const parentTag = tagNameOf(node);
|
|
1234
|
+
const atTopLevel = parentTag === 'body' || parentTag === 'back';
|
|
1025
1235
|
for (const child of childrenOf(node)) {
|
|
1026
1236
|
const tag = tagNameOf(child);
|
|
1027
1237
|
if (!tag)
|
|
@@ -1033,17 +1243,17 @@ function collectSectioned(node, sectionTitle, out, take, inBox = false) {
|
|
|
1033
1243
|
}
|
|
1034
1244
|
const own = inBox
|
|
1035
1245
|
? undefined
|
|
1036
|
-
: tag === 'sec'
|
|
1246
|
+
: tag === 'sec' || (inBack && BACK_SECTION_TAGS.has(tag))
|
|
1037
1247
|
? secTitle(child)
|
|
1038
|
-
:
|
|
1248
|
+
: atTopLevel
|
|
1039
1249
|
? ownBlockTitle(child)
|
|
1040
1250
|
: undefined;
|
|
1041
|
-
collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text');
|
|
1251
|
+
collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text', inBack || tag === 'back');
|
|
1042
1252
|
}
|
|
1043
1253
|
}
|
|
1044
1254
|
function parseTableWrap(tableWrap, sectionTitle) {
|
|
1045
1255
|
const id = attrOf(tableWrap, 'id');
|
|
1046
|
-
const label =
|
|
1256
|
+
const label = floatLabel(findOne(tableWrap, 'label'));
|
|
1047
1257
|
const caption = renderCaption(findOne(tableWrap, 'caption'));
|
|
1048
1258
|
// One line per footnote or paragraph: a footer is a list of statements, and a
|
|
1049
1259
|
// marker run into the previous note (`…dose.*No patient…`) misattributes it. (#185)
|
|
@@ -1184,9 +1394,10 @@ function extractTableRows(table) {
|
|
|
1184
1394
|
* supplementary material sit outside `<body>` entirely — `<floats-group>`
|
|
1185
1395
|
* deposits, `<back>/<sec>` and `<app-group>/<app>` placements, and figures
|
|
1186
1396
|
* hanging off the abstract in `<front>`. Each asset names the innermost
|
|
1187
|
-
* enclosing
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1397
|
+
* enclosing section wherever that sits — a back-matter element read as one
|
|
1398
|
+
* included — an untitled `<sec>` inheriting its parent's title, so the reader
|
|
1399
|
+
* keeps a positional cue; an asset inside no section at all carries no section
|
|
1400
|
+
* name. (#130, #206)
|
|
1190
1401
|
*/
|
|
1191
1402
|
export function extractPmcAssets(root) {
|
|
1192
1403
|
if (!root)
|
|
@@ -1237,13 +1448,20 @@ const PUB_ID_LABELS = {
|
|
|
1237
1448
|
doi: 'DOI',
|
|
1238
1449
|
pmcid: 'PMCID',
|
|
1239
1450
|
};
|
|
1240
|
-
/**
|
|
1451
|
+
/**
|
|
1452
|
+
* Render one `<person-group>` child — a `<name>`, `<collab>`, or `<etal>`. A
|
|
1453
|
+
* name reads `Surname Given`, then any `<suffix>` verbatim (`Miller ER III`).
|
|
1454
|
+
*/
|
|
1241
1455
|
function renderCitationName(node) {
|
|
1242
1456
|
const tag = tagNameOf(node);
|
|
1243
1457
|
if (tag === 'name' || tag === 'string-name') {
|
|
1244
1458
|
const surname = textContent(findOne(node, 'surname'));
|
|
1245
1459
|
const given = textContent(findOne(node, 'given-names'));
|
|
1246
|
-
|
|
1460
|
+
const name = [surname, given].filter(Boolean).join(' ');
|
|
1461
|
+
if (!name)
|
|
1462
|
+
return textContent(node);
|
|
1463
|
+
const suffix = textContent(findOne(node, 'suffix'));
|
|
1464
|
+
return suffix ? `${name} ${suffix}` : name;
|
|
1247
1465
|
}
|
|
1248
1466
|
if (tag === 'collab')
|
|
1249
1467
|
return textContent(node);
|
|
@@ -1315,10 +1533,18 @@ const PERSON_TAGS = new Set(['name', 'string-name', 'collab', 'etal']);
|
|
|
1315
1533
|
const SUPPLIED_ETAL = 'et al.';
|
|
1316
1534
|
/** Any character other than whitespace. */
|
|
1317
1535
|
const VISIBLE_TEXT = /\S/;
|
|
1536
|
+
/**
|
|
1537
|
+
* How much of the citation written so far a `<pub-id>` reads for a literal
|
|
1538
|
+
* prefix naming its type: the prefix, its `:` or `.`, and the whitespace around
|
|
1539
|
+
* them. A bounded window keeps each read constant-time, since every `<pub-id>`
|
|
1540
|
+
* in a citation asks again and reading the whole text would make the walk
|
|
1541
|
+
* quadratic. (#209)
|
|
1542
|
+
*/
|
|
1543
|
+
const ID_PREFIX_WINDOW = 256;
|
|
1318
1544
|
/**
|
|
1319
1545
|
* 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
|
|
1546
|
+
* the end of the citation text written so far ({@link ID_PREFIX_WINDOW}), which
|
|
1547
|
+
* a `<pub-id>` reads for a literal prefix already naming its type. An `<etal>` reads as its own text, or as
|
|
1322
1548
|
* `et al.` when it is empty — as nearly every deposit leaves it.
|
|
1323
1549
|
*/
|
|
1324
1550
|
function renderCitationPart(child, tag, before) {
|
|
@@ -1354,6 +1580,12 @@ function renderCitationPart(child, tag, before) {
|
|
|
1354
1580
|
* - A label this renderer inserts is separated from whatever precedes it. (#115)
|
|
1355
1581
|
* - A source `.` straight after a supplied `et al.` closes it rather than
|
|
1356
1582
|
* doubling its period (`<etal/></person-group>. <article-title>`). (#209)
|
|
1583
|
+
* - The whitespace between an element and the {@link CLOSING_PUNCTUATION}
|
|
1584
|
+
* after it is dropped when any of it lies inside the element or it holds a
|
|
1585
|
+
* line break: the indentation a pretty-printed deposit leaves before
|
|
1586
|
+
* `</person-group>` or `</string-name>`, or the line break after
|
|
1587
|
+
* `</collab>`, reads `Henry L.` rather than `Henry L .`. Spaces the
|
|
1588
|
+
* citation's own text puts there (`</source> : <volume>`) are kept. (#219)
|
|
1357
1589
|
*
|
|
1358
1590
|
* Gaps the source already fills — `, ` text between names, a newline between a
|
|
1359
1591
|
* `<string-name>`'s parts — are kept as written, so a fixed separator never
|
|
@@ -1370,9 +1602,19 @@ function renderCitationRun(node, wrapper) {
|
|
|
1370
1602
|
let text = '';
|
|
1371
1603
|
/** Whitespace not yet written: the end of the last element's text, then source whitespace after it. */
|
|
1372
1604
|
let gap = '';
|
|
1605
|
+
/** `gap` begins inside the element written last. */
|
|
1606
|
+
let gapInside = false;
|
|
1373
1607
|
/** The element written last, while no visible source text has followed it. */
|
|
1374
1608
|
let previous;
|
|
1375
1609
|
let suppliedEtal = false;
|
|
1610
|
+
/** At least the last {@link ID_PREFIX_WINDOW} characters of `text`, at most twice that. */
|
|
1611
|
+
let recent = '';
|
|
1612
|
+
const write = (chunk) => {
|
|
1613
|
+
text += chunk;
|
|
1614
|
+
recent += chunk;
|
|
1615
|
+
if (recent.length > 2 * ID_PREFIX_WINDOW)
|
|
1616
|
+
recent = recent.slice(-ID_PREFIX_WINDOW);
|
|
1617
|
+
};
|
|
1376
1618
|
for (const child of childrenOf(node)) {
|
|
1377
1619
|
if (isTextNode(child)) {
|
|
1378
1620
|
let raw = textOf(child);
|
|
@@ -1382,23 +1624,32 @@ function renderCitationRun(node, wrapper) {
|
|
|
1382
1624
|
}
|
|
1383
1625
|
if (suppliedEtal)
|
|
1384
1626
|
raw = raw.replace(/^(\s*)\./, '$1');
|
|
1385
|
-
|
|
1627
|
+
if (previous !== undefined) {
|
|
1628
|
+
const lead = raw.search(VISIBLE_TEXT);
|
|
1629
|
+
if (CLOSING_PUNCTUATION.has(raw.charAt(lead)) &&
|
|
1630
|
+
(gapInside || gap.includes('\n') || raw.slice(0, lead).includes('\n'))) {
|
|
1631
|
+
gap = '';
|
|
1632
|
+
raw = raw.slice(lead);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
write(gap + raw);
|
|
1386
1636
|
gap = '';
|
|
1387
1637
|
previous = undefined;
|
|
1388
1638
|
suppliedEtal = false;
|
|
1389
1639
|
continue;
|
|
1390
1640
|
}
|
|
1391
1641
|
const tag = tagNameOf(child) ?? '';
|
|
1392
|
-
const part = renderCitationPart(child, tag,
|
|
1642
|
+
const part = renderCitationPart(child, tag, recent);
|
|
1393
1643
|
if (!part.text && (wrapper || tag === 'pub-id'))
|
|
1394
1644
|
continue;
|
|
1395
1645
|
if (previous && PERSON_TAGS.has(previous) && PERSON_TAGS.has(tag))
|
|
1396
|
-
|
|
1646
|
+
write(', ');
|
|
1397
1647
|
else
|
|
1398
|
-
|
|
1648
|
+
write((previous && !gap) || part.labeled ? `${gap} ` : gap);
|
|
1399
1649
|
const body = part.text.trimEnd();
|
|
1400
|
-
|
|
1650
|
+
write(body);
|
|
1401
1651
|
gap = part.text.slice(body.length);
|
|
1652
|
+
gapInside = gap !== '';
|
|
1402
1653
|
previous = tag;
|
|
1403
1654
|
suppliedEtal = part.suppliedEtal;
|
|
1404
1655
|
}
|
|
@@ -1517,6 +1768,7 @@ export function parsePmcArticle(articleNode) {
|
|
|
1517
1768
|
const articleMeta = findOne(front, 'article-meta');
|
|
1518
1769
|
const journalMeta = findOne(front, 'journal-meta');
|
|
1519
1770
|
const body = findOne(articleNode, 'body');
|
|
1771
|
+
const back = findOne(articleNode, 'back');
|
|
1520
1772
|
const pmcId = extractArticleId(articleMeta, 'pmcid') ?? extractArticleId(articleMeta, 'pmc-uid') ?? '';
|
|
1521
1773
|
const pmid = extractArticleId(articleMeta, 'pmid');
|
|
1522
1774
|
const doi = extractArticleId(articleMeta, 'doi');
|
|
@@ -1529,6 +1781,7 @@ export function parsePmcArticle(articleNode) {
|
|
|
1529
1781
|
const abstract = extractAbstract(articleMeta);
|
|
1530
1782
|
const keywords = extractKeywords(articleMeta);
|
|
1531
1783
|
const sections = extractBodySections(body);
|
|
1784
|
+
const backSections = extractBackSections(back);
|
|
1532
1785
|
const references = extractReferences(articleNode);
|
|
1533
1786
|
const tables = extractPmcTables(articleNode);
|
|
1534
1787
|
const assets = extractPmcAssets(articleNode);
|
|
@@ -1546,6 +1799,7 @@ export function parsePmcArticle(articleNode) {
|
|
|
1546
1799
|
...(abstract && { abstract }),
|
|
1547
1800
|
...(keywords.length > 0 && { keywords }),
|
|
1548
1801
|
sections,
|
|
1802
|
+
...(backSections.length > 0 && { backSections }),
|
|
1549
1803
|
...(references.length > 0 && { references }),
|
|
1550
1804
|
...(tables.length > 0 && { tables }),
|
|
1551
1805
|
...(assets.length > 0 && { assets }),
|