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