pubmed_parser/pmc/domain.rs
1//! PMC domain models based on JATS Archiving 1.4 DTD
2//!
3//! **This module is the single model layer for PMC full-text articles.**
4//! All parsing produces these types directly; there is no separate
5//! intermediate parser model.
6//!
7//! The type hierarchy mirrors the JATS `<article>` content model:
8//!
9//! ```text
10//! <article> → PmcArticle
11//! <front> → Front
12//! <journal-meta> → JournalMeta
13//! <article-meta> → ArticleMeta (ids, TitleGroup, contributors,
14//! pub info, history, Permissions, Abstract,
15//! keywords, funding)
16//! <body> → Body (Vec<Section>, recursive)
17//! <back> → Back (ack, COI, Vec<Reference>, appendices, glossary)
18//! ```
19//!
20//! Design principles:
21//! - DTD-faithful: every field maps to a JATS element/attribute, structured
22//! following the DTD hierarchy and declaration order
23//! - No extraction concerns: fields like `file_path` or inferred `file_type` are excluded
24//! - Type-safe IDs: uses `PmcId` / `PubMedId` instead of raw strings
25//! - Reuses shared types: `Author` and `HistoryDate` from `common::models`
26//! - Text mining ready: structured abstracts, table content, formulas, definitions
27//!
28//! Documented deviations from strict DTD structure:
29//! - `supplementary_materials` and `data_availability` live on [`PmcArticle`]
30//! because in real PMC XML they appear in `<body>` sections, `<back>`, or
31//! `<floats-group>`; the parser collects them document-wide without
32//! tracking their position
33//! - `<contrib-group>` is flattened to `Vec<Author>` (only author contribs
34//! are modeled)
35//!
36//! Flattened read access is provided through accessor methods on
37//! [`PmcArticle`] (e.g. [`PmcArticle::title`], [`PmcArticle::sections`]).
38
39use crate::common::{Author, HistoryDate, PmcId, PubMedId, PublicationDate};
40use serde::{Deserialize, Serialize};
41
42// ============================================================================
43// Top-level article
44// ============================================================================
45
46/// PMC full-text article.
47///
48/// Maps to JATS `<article>`: `front, body?, back?`.
49///
50/// DTD: <https://jats.nlm.nih.gov/archiving/tag-library/1.4/element/article.html>
51#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
52pub struct PmcArticle {
53 /// Article type. From `<article article-type="...">` attribute.
54 pub article_type: Option<String>,
55 /// Front matter. From `<front>`.
56 pub front: Front,
57 /// Article body. From `<body>`. `None` when the article has no body
58 /// (e.g., metadata-only records).
59 pub body: Option<Body>,
60 /// Back matter. From `<back>`. `None` when the article has no back matter.
61 pub back: Option<Back>,
62
63 // --- Document-wide collections (deviation from strict DTD placement) ---
64 /// Supplementary materials. From `<supplementary-material>`, collected
65 /// from the entire document (`<body>` sections, `<back>`, or `<floats-group>`).
66 pub supplementary_materials: Vec<SupplementaryMaterial>,
67 /// Data availability statement. From `<sec sec-type="data-availability">`
68 /// or `<notes notes-type="data-availability">`, wherever it appears.
69 pub data_availability: Option<String>,
70}
71
72// ============================================================================
73// Front matter (<front>)
74// ============================================================================
75
76/// Front matter.
77///
78/// Maps to JATS `<front>`: `journal-meta?, article-meta?`.
79#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
80pub struct Front {
81 /// Journal metadata. From `<journal-meta>`.
82 pub journal_meta: JournalMeta,
83 /// Article metadata. From `<article-meta>`.
84 pub article_meta: ArticleMeta,
85}
86
87/// Journal metadata.
88///
89/// Maps to JATS `<journal-meta>`. Note that `volume` and `issue` are intentionally
90/// excluded here — in the DTD they belong to `<article-meta>`, not `<journal-meta>`,
91/// and are placed on [`ArticleMeta`] accordingly.
92#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
93pub struct JournalMeta {
94 /// Journal title. From `<journal-title-group>/<journal-title>`. `None`
95 /// when the element is absent in the XML.
96 pub title: Option<String>,
97 /// Abbreviated journal title. From `<journal-id journal-id-type="iso-abbrev">`
98 /// or `<abbrev-journal-title>`.
99 pub abbreviation: Option<String>,
100 /// Print ISSN. From `<issn pub-type="ppub">`.
101 pub issn_print: Option<String>,
102 /// Electronic ISSN. From `<issn pub-type="epub">`.
103 pub issn_electronic: Option<String>,
104 /// Publisher name. From `<publisher>/<publisher-name>`.
105 pub publisher: Option<String>,
106}
107
108/// Article metadata.
109///
110/// Maps to JATS `<article-meta>`. Fields follow the DTD declaration order:
111/// `article-id*, article-categories?, title-group, contrib-group*, pub-date*,
112/// volume?, issue?, fpage?, lpage?, elocation-id?, history?, permissions?,
113/// abstract*, kwd-group*, funding-group*`.
114#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
115pub struct ArticleMeta {
116 // --- Identifiers (<article-id>) ---
117 /// PMC ID (e.g., PMC7906746). From `<article-id pub-id-type="pmc">`.
118 pub pmcid: PmcId,
119 /// PubMed ID. From `<article-id pub-id-type="pmid">`.
120 pub pmid: Option<PubMedId>,
121 /// DOI. From `<article-id pub-id-type="doi">`.
122 pub doi: Option<String>,
123
124 /// Subject categories. From `<article-categories>/<subj-group>/<subject>`.
125 pub categories: Vec<String>,
126
127 /// Title group. From `<title-group>`.
128 pub title_group: TitleGroup,
129
130 /// Authors. From `<contrib-group>/<contrib contrib-type="author">`.
131 pub authors: Vec<Author>,
132
133 /// Publication dates (epub, ppub, collection, etc.). From `<pub-date>`.
134 pub pub_dates: Vec<PublicationDate>,
135 /// Volume number. From `<volume>`.
136 pub volume: Option<String>,
137 /// Issue number. From `<issue>`.
138 pub issue: Option<String>,
139 /// First page. From `<fpage>`.
140 pub fpage: Option<String>,
141 /// Last page. From `<lpage>`.
142 pub lpage: Option<String>,
143 /// Electronic location ID. From `<elocation-id>`.
144 pub elocation_id: Option<String>,
145
146 /// Publication history dates. From `<history>/<date>`.
147 pub history: Vec<HistoryDate>,
148
149 /// Copyright and licensing. From `<permissions>`.
150 pub permissions: Option<Permissions>,
151
152 /// Abstracts. From `<abstract>` (repeatable in the DTD, e.g. a main
153 /// abstract plus a graphical or teaser abstract).
154 pub abstracts: Vec<Abstract>,
155
156 /// Keywords (flattened across all groups). From `<kwd-group>/<kwd>`.
157 pub keywords: Vec<String>,
158
159 /// Keyword groups, preserving `kwd-group-type` and `xml:lang`. From
160 /// `<kwd-group>`. This is the structured counterpart of [`keywords`](Self::keywords),
161 /// distinguishing e.g. author-supplied keywords from MeSH-derived ones.
162 #[serde(default)]
163 pub keyword_groups: Vec<KeywordGroup>,
164
165 /// Subject groups, preserving `subj-group-type`. From
166 /// `<article-categories>/<subj-group>`. Structured counterpart of
167 /// [`categories`](Self::categories).
168 #[serde(default)]
169 pub subject_groups: Vec<SubjectGroup>,
170
171 /// Related articles (corrections, retractions, companion/peer-reviewed
172 /// articles). From `<related-article>`.
173 #[serde(default)]
174 pub related_articles: Vec<RelatedArticle>,
175
176 /// Author notes (correspondence details, equal-contribution and present-
177 /// address statements). From `<author-notes>` (`<corresp>` / `<fn>`).
178 #[serde(default)]
179 pub author_notes: Vec<String>,
180
181 /// Funding information. From `<funding-group>/<award-group>`.
182 pub funding: Vec<FundingInfo>,
183}
184
185/// A keyword group.
186///
187/// Maps to JATS `<kwd-group>`. Articles frequently carry several groups — e.g.
188/// author-supplied keywords plus publisher subject terms — distinguished by
189/// `@kwd-group-type` and `@xml:lang`.
190#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
191pub struct KeywordGroup {
192 /// Group type (e.g., "author", "npg-subject"). From `@kwd-group-type`.
193 pub group_type: Option<String>,
194 /// Language of the keywords. From `@xml:lang`.
195 pub lang: Option<String>,
196 /// Keywords in this group. From `<kwd>`.
197 pub keywords: Vec<String>,
198}
199
200/// A subject (article-category) group.
201///
202/// Maps to JATS `<article-categories>/<subj-group>`. `@subj-group-type` carries
203/// the role of the terms (e.g. "heading", "discipline").
204#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
205pub struct SubjectGroup {
206 /// Group type (e.g., "heading", "discipline"). From `@subj-group-type`.
207 pub group_type: Option<String>,
208 /// Subjects in this group. From `<subject>`.
209 pub subjects: Vec<String>,
210}
211
212/// A link to a related article.
213///
214/// Maps to JATS `<related-article>`. The `related_article_type` drives common
215/// text-mining use cases such as detecting corrections and retractions
216/// (e.g. `"corrected-article"`, `"retracted-article"`, `"companion"`,
217/// `"peer-reviewed-article"`).
218#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
219pub struct RelatedArticle {
220 /// Relationship type. From `@related-article-type`.
221 pub related_article_type: Option<String>,
222 /// External link type (e.g., "doi", "pmc"). From `@ext-link-type`.
223 pub ext_link_type: Option<String>,
224 /// Link target (DOI, PMC id, URL). From `@xlink:href`.
225 pub href: Option<String>,
226 /// Element id. From `@id`.
227 pub id: Option<String>,
228}
229
230impl RelatedArticle {
231 /// Whether this link points to an article corrected by the current one
232 /// (`related-article-type="corrected-article"`).
233 pub fn is_correction(&self) -> bool {
234 self.related_article_type.as_deref() == Some("corrected-article")
235 }
236
237 /// Whether this link points to an article retracted by the current one
238 /// (`related-article-type="retracted-article"`).
239 pub fn is_retraction(&self) -> bool {
240 self.related_article_type.as_deref() == Some("retracted-article")
241 }
242}
243
244/// Title group.
245///
246/// Maps to JATS `<title-group>`.
247#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
248pub struct TitleGroup {
249 /// Article title. From `<article-title>`. `None` when the element is
250 /// absent in the XML.
251 pub article_title: Option<String>,
252 /// Article subtitle. From `<subtitle>`.
253 pub subtitle: Option<String>,
254}
255
256/// Copyright and licensing information.
257///
258/// Maps to JATS `<permissions>`.
259#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
260pub struct Permissions {
261 /// Copyright statement. From `<copyright-statement>`
262 /// (falls back to `<copyright-year>`).
263 pub copyright_statement: Option<String>,
264 /// License. From `<license>`.
265 pub license: Option<License>,
266}
267
268/// License information.
269///
270/// Maps to JATS `<license>`.
271#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
272pub struct License {
273 /// License URL. From `<license xlink:href="...">` or `<ali:license_ref>`.
274 pub href: Option<String>,
275 /// License text. From `<license-p>` content.
276 pub text: Option<String>,
277}
278
279/// Abstract.
280///
281/// Maps to JATS `<abstract>`. The DTD allows multiple abstracts
282/// distinguished by `@abstract-type`.
283#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
284pub struct Abstract {
285 /// Abstract type (e.g., "graphical", "teaser"). From `<abstract abstract-type="...">`.
286 pub abstract_type: Option<String>,
287 /// Plain abstract text (flattened). Concatenation of all `<p>` texts.
288 pub text: String,
289 /// Structured abstract sections. From `<abstract>/<sec>`.
290 /// Present when the abstract has labeled sections (e.g., Background, Methods, Results).
291 pub sections: Vec<AbstractSection>,
292}
293
294/// Structured abstract section.
295///
296/// Maps to `<abstract>/<sec>`. Many biomedical journals use structured abstracts
297/// with labeled sections (Background, Methods, Results, Conclusions).
298/// This structure preserves the section boundaries for text mining pipelines
299/// that need to process abstract sections independently.
300#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
301pub struct AbstractSection {
302 /// Section label (e.g., "Background", "Methods", "Results", "Conclusions").
303 /// From `<title>` inside `<abstract>/<sec>`.
304 pub label: Option<String>,
305 /// Section text content. From `<p>` inside `<abstract>/<sec>`.
306 pub text: String,
307}
308
309// ============================================================================
310// Body content (<body>)
311// ============================================================================
312
313/// Article body.
314///
315/// Maps to JATS `<body>`: `(p | sec | ...)*`.
316#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
317pub struct Body {
318 /// Article sections. From `<body>/<sec>`.
319 pub sections: Vec<Section>,
320}
321
322/// Article section.
323///
324/// Maps to JATS `<sec>`. Sections form a recursive tree via `subsections`.
325/// Figures, tables, and formulas that appear inline within the section are
326/// collected in their respective fields.
327#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
328pub struct Section {
329 /// Section ID. From `<sec id="...">`.
330 pub id: Option<String>,
331 /// Section type. From `<sec sec-type="...">`.
332 /// Common values: `"intro"`, `"methods"`, `"results"`, `"discussion"`,
333 /// `"conclusions"`, `"supplementary-material"`, `"data-availability"`.
334 pub section_type: Option<String>,
335 /// Section label/number (e.g., "1.", "2.1"). From `<label>`.
336 pub label: Option<String>,
337 /// Section title. From `<title>`.
338 pub title: Option<String>,
339 /// Concatenated paragraph text. From `<p>` elements.
340 pub content: String,
341 /// Nested subsections. From child `<sec>` elements.
342 pub subsections: Vec<Section>,
343 /// Figures within this section. From `<fig>` elements.
344 pub figures: Vec<Figure>,
345 /// Tables within this section. From `<table-wrap>` elements.
346 pub tables: Vec<Table>,
347 /// Display formulas within this section. From `<disp-formula>` elements.
348 pub formulas: Vec<Formula>,
349 /// Reference ids cited in this section's paragraphs, in document order.
350 /// From `<xref ref-type="bibr" rid="...">`; each id targets a
351 /// [`Reference::id`] in [`Back::references`], so consumers can join the two
352 /// for citation-context / citation-graph analysis. A grouped citation
353 /// (`rid="B1 B2"`) contributes each id separately.
354 pub cited_reference_ids: Vec<String>,
355}
356
357/// Figure.
358///
359/// Maps to JATS `<fig>`. The `graphic_href` field contains the domain-level
360/// reference to the graphic file (from `<graphic xlink:href="...">`).
361/// Actual file extraction paths and sizes belong to the client layer's
362/// `ExtractedFigure` type, not here.
363#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
364pub struct Figure {
365 /// Figure ID. From `<fig id="...">`.
366 pub id: String,
367 /// Figure label (e.g., "Figure 1"). From `<label>`.
368 pub label: Option<String>,
369 /// Figure caption. From `<caption>/<p>`. `None` when the XML element is
370 /// absent or could not be parsed.
371 pub caption: Option<String>,
372 /// Alt text. From `<alt-text>`.
373 pub alt_text: Option<String>,
374 /// Figure type (e.g., "figure", "scheme", "chart"). From `<fig fig-type="...">`.
375 pub fig_type: Option<String>,
376 /// Graphic href from the XML. From `<graphic xlink:href="...">`.
377 pub graphic_href: Option<String>,
378}
379
380/// Table wrapper.
381///
382/// Maps to JATS `<table-wrap>`. Table body is parsed into structured rows/cells
383/// for direct programmatic access without requiring downstream HTML parsing.
384#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
385pub struct Table {
386 /// Table ID. From `<table-wrap id="...">`.
387 pub id: String,
388 /// Table label (e.g., "Table 1"). From `<label>`.
389 pub label: Option<String>,
390 /// Table caption. From `<caption>/<p>`. `None` when the XML element is
391 /// absent or could not be parsed.
392 pub caption: Option<String>,
393 /// Header rows. From `<thead>/<tr>`.
394 pub head: Vec<TableRow>,
395 /// Body rows. From `<tbody>/<tr>` (or direct `<tr>` if no `<tbody>`).
396 pub body: Vec<TableRow>,
397 /// Table footnotes. From `<table-wrap-foot>/<fn>`.
398 pub footnotes: Vec<String>,
399}
400
401/// A single table row.
402///
403/// Maps to XHTML `<tr>` inside JATS `<table>`.
404#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
405pub struct TableRow {
406 /// Cells in this row. From `<th>` or `<td>` elements.
407 pub cells: Vec<TableCell>,
408}
409
410/// A single table cell.
411///
412/// Maps to XHTML `<th>` or `<td>` inside JATS `<table>`.
413#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
414pub struct TableCell {
415 /// Cell text content (XML tags stripped).
416 pub content: String,
417 /// Whether this is a header cell (`<th>`) vs data cell (`<td>`).
418 pub is_header: bool,
419 /// Column span. From `@colspan` attribute.
420 pub colspan: Option<u32>,
421 /// Row span. From `@rowspan` attribute.
422 pub rowspan: Option<u32>,
423}
424
425/// Display formula.
426///
427/// Maps to JATS `<disp-formula>`. Formulas can be represented as MathML,
428/// TeX/LaTeX, plain text, or as graphic images. The `notation` field indicates
429/// which representation is stored in `content`.
430#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
431pub struct Formula {
432 /// Formula ID. From `<disp-formula id="...">`.
433 pub id: Option<String>,
434 /// Formula label (e.g., "1", "(1)"). From `<label>`.
435 pub label: Option<String>,
436 /// Formula content. From `<tex-math>`, `<mml:math>`, or text content.
437 pub content: Option<String>,
438 /// Notation type indicating the format of `content`.
439 /// `"tex"` for `<tex-math>`, `"mathml"` for `<mml:math>`, `"text"` for plain text.
440 pub notation: Option<String>,
441 /// Graphic href for image-based formulas. From `<graphic xlink:href="...">`.
442 pub graphic_href: Option<String>,
443}
444
445// ============================================================================
446// Back matter (<back>)
447// ============================================================================
448
449/// Back matter.
450///
451/// Maps to JATS `<back>`: `(ack | app-group | bio | fn-group | glossary |
452/// ref-list | notes | sec)*`.
453#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
454pub struct Back {
455 /// Acknowledgments. From `<ack>`.
456 pub acknowledgments: Option<String>,
457 /// Conflict of interest statement. From `<fn-group>/<fn fn-type="COI-statement">`
458 /// or `<sec>` whose title mentions conflicts/competing interests.
459 pub conflict_of_interest: Option<String>,
460 /// Reference list. From `<ref-list>/<ref>`.
461 pub references: Vec<Reference>,
462 /// Appendices. From `<app-group>/<app>`.
463 pub appendices: Vec<Section>,
464 /// Glossary definitions. From `<glossary>/<def-list>`.
465 pub glossary: Vec<Definition>,
466}
467
468/// Reference citation.
469///
470/// Maps to JATS `<ref>` containing `<element-citation>` or `<mixed-citation>`.
471#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
472pub struct Reference {
473 /// Reference ID. From `<ref id="...">`.
474 pub id: String,
475 /// Publication type. From `<element-citation publication-type="...">`.
476 /// Common values: `"journal"`, `"book"`, `"web"`, `"other"`.
477 pub publication_type: Option<String>,
478 /// Article or chapter title. From `<article-title>` or `<chapter-title>`.
479 pub title: Option<String>,
480 /// Authors. From `<person-group>/<name>`.
481 pub authors: Vec<Author>,
482 /// Source (journal name or book title). From `<source>`.
483 pub source: Option<String>,
484 /// Publication year. From `<year>`.
485 pub year: Option<String>,
486 /// Volume. From `<volume>`.
487 pub volume: Option<String>,
488 /// Issue. From `<issue>`.
489 pub issue: Option<String>,
490 /// Page range. From `<fpage>`-`<lpage>`.
491 pub pages: Option<String>,
492 /// Electronic location ID. From `<elocation-id>` (used by e-journals in
493 /// place of page numbers).
494 #[serde(default)]
495 pub elocation_id: Option<String>,
496 /// Editors. From `<person-group person-group-type="editor">` (common for
497 /// book and book-chapter citations).
498 #[serde(default)]
499 pub editors: Vec<Author>,
500 /// Publisher name. From `<publisher-name>` (book / report citations).
501 #[serde(default)]
502 pub publisher_name: Option<String>,
503 /// Publisher location. From `<publisher-loc>`.
504 #[serde(default)]
505 pub publisher_loc: Option<String>,
506 /// Edition. From `<edition>`.
507 #[serde(default)]
508 pub edition: Option<String>,
509 /// ISBN. From `<isbn>` (book citations).
510 #[serde(default)]
511 pub isbn: Option<String>,
512 /// Conference name. From `<conf-name>` (conference-proceedings citations).
513 #[serde(default)]
514 pub conf_name: Option<String>,
515 /// PubMed ID. From `<pub-id pub-id-type="pmid">`.
516 pub pmid: Option<String>,
517 /// DOI. From `<pub-id pub-id-type="doi">`.
518 pub doi: Option<String>,
519}
520
521impl Reference {
522 /// Format a human-readable citation string.
523 pub fn format_citation(&self) -> String {
524 let mut parts = Vec::new();
525
526 if !self.authors.is_empty() {
527 let author_names: Vec<String> = self
528 .authors
529 .iter()
530 .map(|a| a.full_name.clone())
531 .filter(|n| !n.trim().is_empty())
532 .collect();
533 if !author_names.is_empty() {
534 parts.push(author_names.join(", "));
535 }
536 }
537
538 if let Some(title) = &self.title
539 && !title.trim().is_empty()
540 {
541 parts.push(title.clone());
542 }
543
544 if let Some(source) = &self.source
545 && !source.trim().is_empty()
546 {
547 let mut source_part = source.clone();
548 if let Some(year) = &self.year
549 && !year.trim().is_empty()
550 && year != "n.d."
551 {
552 source_part.push_str(&format!(" ({year})"));
553 }
554 if let Some(volume) = &self.volume
555 && !volume.trim().is_empty()
556 {
557 source_part.push_str(&format!(" {volume}"));
558 if let Some(issue) = &self.issue
559 && !issue.trim().is_empty()
560 {
561 source_part.push_str(&format!("({issue})"));
562 }
563 }
564 if let Some(pages) = &self.pages
565 && !pages.trim().is_empty()
566 {
567 source_part.push_str(&format!(": {pages}"));
568 }
569 parts.push(source_part);
570 }
571
572 let result = parts.join(". ");
573 if result.trim().is_empty() {
574 format!("Reference {}", self.id)
575 } else {
576 result
577 }
578 }
579}
580
581/// Funding information.
582///
583/// Maps to JATS `<funding-group>/<award-group>`.
584#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
585pub struct FundingInfo {
586 /// Funding source/agency. From `<funding-source>`. `None` when the element
587 /// is absent in the XML.
588 pub source: Option<String>,
589 /// Grant/award ID. From `<award-id>`.
590 pub award_id: Option<String>,
591 /// Funding statement. From `<funding-statement>`.
592 pub statement: Option<String>,
593}
594
595/// Supplementary material.
596///
597/// Maps to JATS `<supplementary-material>`. Only contains domain-level
598/// data from the XML. Inferred values like file type (derived from URL extension)
599/// and layout attributes like `position` are excluded.
600#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
601pub struct SupplementaryMaterial {
602 /// Material ID. From `<supplementary-material id="...">`.
603 pub id: String,
604 /// Content type. From `<supplementary-material content-type="...">`.
605 pub content_type: Option<String>,
606 /// Title or label. From `<label>` or `<caption>/<title>`.
607 pub title: Option<String>,
608 /// Description. From `<caption>/<p>`.
609 pub description: Option<String>,
610 /// Resource href. From `<supplementary-material xlink:href="...">`.
611 pub href: Option<String>,
612}
613
614impl SupplementaryMaterial {
615 /// Check if this material is a tar archive based on the href extension.
616 pub fn is_tar_file(&self) -> bool {
617 if let Some(url) = &self.href {
618 url.ends_with(".tar")
619 || url.ends_with(".tar.gz")
620 || url.ends_with(".tar.bz2")
621 || url.ends_with(".tgz")
622 } else {
623 false
624 }
625 }
626
627 /// Get file extension from the href.
628 pub fn get_file_extension(&self) -> Option<String> {
629 if let Some(url) = &self.href
630 && let Some(filename) = url.split('/').next_back()
631 && let Some(dot_index) = filename.rfind('.')
632 {
633 return Some(filename[dot_index + 1..].to_lowercase());
634 }
635 None
636 }
637
638 /// Check if this is an archive file (zip, tar, etc.).
639 pub fn is_archive(&self) -> bool {
640 if let Some(ext) = self.get_file_extension() {
641 matches!(
642 ext.as_str(),
643 "zip" | "tar" | "gz" | "bz2" | "tgz" | "rar" | "7z"
644 )
645 } else {
646 false
647 }
648 }
649}
650
651// ============================================================================
652// Text mining support types
653// ============================================================================
654
655/// Term definition.
656///
657/// Maps to JATS `<def-list>/<def-item>`. Used for abbreviation lists and
658/// glossaries commonly found in biomedical articles.
659///
660/// Example XML:
661/// ```xml
662/// <def-item>
663/// <term>HPV</term>
664/// <def><p>Human papillomavirus</p></def>
665/// </def-item>
666/// ```
667#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
668pub struct Definition {
669 /// Term being defined. From `<term>`.
670 pub term: String,
671 /// Definition text. From `<def>/<p>`.
672 pub definition: String,
673}
674
675// ============================================================================
676// Aggregate accessors
677// ============================================================================
678
679impl PmcArticle {
680 /// PMC ID of this article (aggregate identity).
681 pub fn pmcid(&self) -> &PmcId {
682 &self.front.article_meta.pmcid
683 }
684
685 /// PubMed ID, if present.
686 pub fn pmid(&self) -> Option<&PubMedId> {
687 self.front.article_meta.pmid.as_ref()
688 }
689
690 /// DOI, if present.
691 pub fn doi(&self) -> Option<&str> {
692 self.front.article_meta.doi.as_deref()
693 }
694
695 /// Article title, if present.
696 pub fn title(&self) -> Option<&str> {
697 self.front.article_meta.title_group.article_title.as_deref()
698 }
699
700 /// Article subtitle, if present.
701 pub fn subtitle(&self) -> Option<&str> {
702 self.front.article_meta.title_group.subtitle.as_deref()
703 }
704
705 /// Authors.
706 pub fn authors(&self) -> &[Author] {
707 &self.front.article_meta.authors
708 }
709
710 /// Journal metadata.
711 pub fn journal(&self) -> &JournalMeta {
712 &self.front.journal_meta
713 }
714
715 /// Publication dates.
716 pub fn pub_dates(&self) -> &[PublicationDate] {
717 &self.front.article_meta.pub_dates
718 }
719
720 /// Subject categories.
721 pub fn categories(&self) -> &[String] {
722 &self.front.article_meta.categories
723 }
724
725 /// Volume number, if present.
726 pub fn volume(&self) -> Option<&str> {
727 self.front.article_meta.volume.as_deref()
728 }
729
730 /// Issue number, if present.
731 pub fn issue(&self) -> Option<&str> {
732 self.front.article_meta.issue.as_deref()
733 }
734
735 /// Keywords.
736 pub fn keywords(&self) -> &[String] {
737 &self.front.article_meta.keywords
738 }
739
740 /// Funding information.
741 pub fn funding(&self) -> &[FundingInfo] {
742 &self.front.article_meta.funding
743 }
744
745 /// Publication history dates.
746 pub fn history(&self) -> &[HistoryDate] {
747 &self.front.article_meta.history
748 }
749
750 /// Text of the main abstract (first `<abstract>`), if present.
751 pub fn abstract_text(&self) -> Option<&str> {
752 self.front
753 .article_meta
754 .abstracts
755 .first()
756 .map(|a| a.text.as_str())
757 }
758
759 /// Copyright statement, if present.
760 pub fn copyright(&self) -> Option<&str> {
761 self.front
762 .article_meta
763 .permissions
764 .as_ref()
765 .and_then(|p| p.copyright_statement.as_deref())
766 }
767
768 /// License text, if present.
769 pub fn license_text(&self) -> Option<&str> {
770 self.front
771 .article_meta
772 .permissions
773 .as_ref()
774 .and_then(|p| p.license.as_ref())
775 .and_then(|l| l.text.as_deref())
776 }
777
778 /// License URL, if present.
779 pub fn license_url(&self) -> Option<&str> {
780 self.front
781 .article_meta
782 .permissions
783 .as_ref()
784 .and_then(|p| p.license.as_ref())
785 .and_then(|l| l.href.as_deref())
786 }
787
788 /// Body sections (empty slice when the article has no body).
789 pub fn sections(&self) -> &[Section] {
790 self.body.as_ref().map_or(&[], |b| b.sections.as_slice())
791 }
792
793 /// References (empty slice when the article has no back matter).
794 pub fn references(&self) -> &[Reference] {
795 self.back.as_ref().map_or(&[], |b| b.references.as_slice())
796 }
797
798 /// Acknowledgments, if present.
799 pub fn acknowledgments(&self) -> Option<&str> {
800 self.back
801 .as_ref()
802 .and_then(|b| b.acknowledgments.as_deref())
803 }
804
805 /// Conflict of interest statement, if present.
806 pub fn conflict_of_interest(&self) -> Option<&str> {
807 self.back
808 .as_ref()
809 .and_then(|b| b.conflict_of_interest.as_deref())
810 }
811
812 /// Get tar files from supplementary materials.
813 pub fn get_tar_files(&self) -> Vec<&SupplementaryMaterial> {
814 self.supplementary_materials
815 .iter()
816 .filter(|m| m.is_tar_file())
817 .collect()
818 }
819
820 /// Get all archive files from supplementary materials.
821 pub fn get_archive_files(&self) -> Vec<&SupplementaryMaterial> {
822 self.supplementary_materials
823 .iter()
824 .filter(|m| m.is_archive())
825 .collect()
826 }
827
828 /// Check if the article has supplementary materials.
829 pub fn has_supplementary_materials(&self) -> bool {
830 !self.supplementary_materials.is_empty()
831 }
832
833 /// Get supplementary materials by content type.
834 pub fn get_supplementary_materials_by_type(
835 &self,
836 content_type: &str,
837 ) -> Vec<&SupplementaryMaterial> {
838 self.supplementary_materials
839 .iter()
840 .filter(|m| m.content_type.as_ref().is_some_and(|ct| ct == content_type))
841 .collect()
842 }
843}
844
845// ============================================================================
846// Section classification
847// ============================================================================
848
849/// Semantic classification of a JATS `<sec sec-type="...">` value.
850///
851/// The DTD leaves `@sec-type` open-ended, but the NLM/PMC tagging guidelines use
852/// a recognized vocabulary for the standard parts of a research article (IMRaD
853/// and friends). This enum maps those well-known values to type-safe variants so
854/// text-mining pipelines can match on section role without string juggling,
855/// while preserving any unrecognized value via [`SectionKind::Other`].
856///
857/// Use [`Section::kind`] to obtain this from a section.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
859pub enum SectionKind {
860 /// `intro` / `introduction`.
861 Introduction,
862 /// `methods` / `materials` / `materials|methods` / `methods|materials`.
863 Methods,
864 /// `results`.
865 Results,
866 /// `results|discussion`.
867 ResultsDiscussion,
868 /// `discussion`.
869 Discussion,
870 /// `conclusions`.
871 Conclusions,
872 /// `abstract`.
873 Abstract,
874 /// `background`.
875 Background,
876 /// `cases` / `case` / `case-report`.
877 CaseStudy,
878 /// `supplementary-material`.
879 SupplementaryMaterial,
880 /// `data-availability`.
881 DataAvailability,
882 /// A `sec-type` that is present but not one of the recognized values.
883 Other(String),
884 /// No `sec-type` attribute was present on the section.
885 Unspecified,
886}
887
888impl SectionKind {
889 /// Classify a raw `sec-type` attribute value.
890 ///
891 /// Matching is case-insensitive and tolerant of the `|` separator that JATS
892 /// uses for combined sections (e.g. `materials|methods`).
893 pub fn from_sec_type(sec_type: Option<&str>) -> Self {
894 let Some(raw) = sec_type else {
895 return SectionKind::Unspecified;
896 };
897 let normalized = raw.trim().to_ascii_lowercase();
898 match normalized.as_str() {
899 "intro" | "introduction" => SectionKind::Introduction,
900 "methods" | "materials" | "methods|materials" | "materials|methods"
901 | "subjects|methods" => SectionKind::Methods,
902 "results" => SectionKind::Results,
903 "results|discussion" | "discussion|results" => SectionKind::ResultsDiscussion,
904 "discussion" => SectionKind::Discussion,
905 "conclusions" | "conclusion" => SectionKind::Conclusions,
906 "abstract" => SectionKind::Abstract,
907 "background" => SectionKind::Background,
908 "cases" | "case" | "case-report" | "case-study" => SectionKind::CaseStudy,
909 "supplementary-material" => SectionKind::SupplementaryMaterial,
910 "data-availability" | "availability" => SectionKind::DataAvailability,
911 _ => SectionKind::Other(normalized),
912 }
913 }
914
915 /// Whether this is one of the core IMRaD sections
916 /// (introduction, methods, results, discussion, conclusions).
917 pub fn is_imrad(&self) -> bool {
918 matches!(
919 self,
920 SectionKind::Introduction
921 | SectionKind::Methods
922 | SectionKind::Results
923 | SectionKind::ResultsDiscussion
924 | SectionKind::Discussion
925 | SectionKind::Conclusions
926 )
927 }
928}
929
930/// Depth-first, pre-order iterator over a section subtree.
931///
932/// Yields sections in document order: a section is yielded before its
933/// subsections. Created by [`Section::iter_subtree`] and
934/// [`PmcArticle::all_sections`].
935pub struct SectionIter<'a> {
936 stack: Vec<&'a Section>,
937}
938
939impl<'a> Iterator for SectionIter<'a> {
940 type Item = &'a Section;
941
942 fn next(&mut self) -> Option<&'a Section> {
943 let section = self.stack.pop()?;
944 // Push children in reverse so they are popped in document order.
945 self.stack.extend(section.subsections.iter().rev());
946 Some(section)
947 }
948}
949
950impl Section {
951 /// Semantic classification of this section's `sec-type`.
952 pub fn kind(&self) -> SectionKind {
953 SectionKind::from_sec_type(self.section_type.as_deref())
954 }
955
956 /// Iterate this section and all of its nested subsections, depth-first in
957 /// document order (pre-order: parent before children).
958 pub fn iter_subtree(&self) -> SectionIter<'_> {
959 SectionIter { stack: vec![self] }
960 }
961
962 /// All figures in this section and its subsections (recursive).
963 pub fn all_figures(&self) -> Vec<&Figure> {
964 self.iter_subtree().flat_map(|s| s.figures.iter()).collect()
965 }
966
967 /// All tables in this section and its subsections (recursive).
968 pub fn all_tables(&self) -> Vec<&Table> {
969 self.iter_subtree().flat_map(|s| s.tables.iter()).collect()
970 }
971
972 /// All display formulas in this section and its subsections (recursive).
973 pub fn all_formulas(&self) -> Vec<&Formula> {
974 self.iter_subtree()
975 .flat_map(|s| s.formulas.iter())
976 .collect()
977 }
978
979 /// All cited reference ids in this section and its subsections (recursive),
980 /// in document order. See [`Section::cited_reference_ids`].
981 pub fn all_cited_reference_ids(&self) -> Vec<&String> {
982 self.iter_subtree()
983 .flat_map(|s| s.cited_reference_ids.iter())
984 .collect()
985 }
986
987 /// Whitespace-delimited word count of this section's own `content`
988 /// (not including subsections). Useful for readability/length metrics.
989 pub fn word_count(&self) -> usize {
990 self.content.split_whitespace().count()
991 }
992
993 /// Whether this section carries no text and no child structure.
994 pub fn is_empty(&self) -> bool {
995 self.content.trim().is_empty()
996 && self.subsections.is_empty()
997 && self.figures.is_empty()
998 && self.tables.is_empty()
999 && self.formulas.is_empty()
1000 }
1001}
1002
1003impl Abstract {
1004 /// Whether this abstract has labeled/structured sections.
1005 pub fn is_structured(&self) -> bool {
1006 !self.sections.is_empty()
1007 }
1008
1009 /// Find a structured abstract section by label (case-insensitive match).
1010 ///
1011 /// Returns the first section whose label equals `label` ignoring case, e.g.
1012 /// `abstract.section_by_label("methods")`.
1013 pub fn section_by_label(&self, label: &str) -> Option<&AbstractSection> {
1014 self.sections.iter().find(|s| {
1015 s.label
1016 .as_ref()
1017 .is_some_and(|l| l.eq_ignore_ascii_case(label))
1018 })
1019 }
1020}
1021
1022impl Table {
1023 /// Total number of rows (header rows plus body rows).
1024 pub fn row_count(&self) -> usize {
1025 self.head.len() + self.body.len()
1026 }
1027
1028 /// Iterate all rows, header rows first then body rows.
1029 pub fn rows(&self) -> impl Iterator<Item = &TableRow> {
1030 self.head.iter().chain(self.body.iter())
1031 }
1032
1033 /// Number of columns, accounting for `colspan`. Computed as the maximum
1034 /// spanned cell count across all rows (0 for an empty table).
1035 pub fn column_count(&self) -> usize {
1036 self.rows()
1037 .map(|row| {
1038 row.cells
1039 .iter()
1040 .map(|c| c.colspan.unwrap_or(1).max(1) as usize)
1041 .sum()
1042 })
1043 .max()
1044 .unwrap_or(0)
1045 }
1046
1047 /// Whether the table has no rows.
1048 pub fn is_empty(&self) -> bool {
1049 self.head.is_empty() && self.body.is_empty()
1050 }
1051}
1052
1053impl Reference {
1054 /// Whether this reference carries a PubMed ID.
1055 pub fn has_pmid(&self) -> bool {
1056 self.pmid.is_some()
1057 }
1058
1059 /// Whether this reference carries a DOI.
1060 pub fn has_doi(&self) -> bool {
1061 self.doi.is_some()
1062 }
1063}
1064
1065// ============================================================================
1066// Article-wide text-mining accessors
1067// ============================================================================
1068
1069impl PmcArticle {
1070 /// Iterate every body section in the article, flattened depth-first in
1071 /// document order (parent before children).
1072 ///
1073 /// This walks the entire `<body>` section tree so callers don't have to
1074 /// recurse through [`Section::subsections`] manually.
1075 pub fn all_sections(&self) -> SectionIter<'_> {
1076 SectionIter {
1077 stack: self.sections().iter().rev().collect(),
1078 }
1079 }
1080
1081 /// All body sections whose semantic [`SectionKind`] matches `kind`
1082 /// (recursive). For example, `article.sections_of_kind(&SectionKind::Methods)`.
1083 pub fn sections_of_kind(&self, kind: &SectionKind) -> Vec<&Section> {
1084 self.all_sections().filter(|s| &s.kind() == kind).collect()
1085 }
1086
1087 /// All body sections whose raw `sec-type` equals `sec_type`
1088 /// (case-insensitive, recursive).
1089 pub fn sections_by_type(&self, sec_type: &str) -> Vec<&Section> {
1090 self.all_sections()
1091 .filter(|s| {
1092 s.section_type
1093 .as_ref()
1094 .is_some_and(|t| t.eq_ignore_ascii_case(sec_type))
1095 })
1096 .collect()
1097 }
1098
1099 /// First body section whose title contains `keyword` (case-insensitive,
1100 /// recursive). Handy for locating sections like "Statistical analysis".
1101 pub fn find_section_by_title(&self, keyword: &str) -> Option<&Section> {
1102 let needle = keyword.to_ascii_lowercase();
1103 self.all_sections().find(|s| {
1104 s.title
1105 .as_ref()
1106 .is_some_and(|t| t.to_ascii_lowercase().contains(&needle))
1107 })
1108 }
1109
1110 /// All figures across the whole article body (recursive over sections).
1111 pub fn all_figures(&self) -> Vec<&Figure> {
1112 self.all_sections().flat_map(|s| s.figures.iter()).collect()
1113 }
1114
1115 /// All tables across the whole article body (recursive over sections).
1116 pub fn all_tables(&self) -> Vec<&Table> {
1117 self.all_sections().flat_map(|s| s.tables.iter()).collect()
1118 }
1119
1120 /// All display formulas across the whole article body (recursive).
1121 pub fn all_formulas(&self) -> Vec<&Formula> {
1122 self.all_sections()
1123 .flat_map(|s| s.formulas.iter())
1124 .collect()
1125 }
1126
1127 /// All cited reference ids across the whole article body, in document order
1128 /// (recursive over sections). Each id targets a [`Reference::id`] in
1129 /// [`Back::references`]. See [`Section::cited_reference_ids`].
1130 pub fn all_cited_reference_ids(&self) -> Vec<&String> {
1131 self.all_sections()
1132 .flat_map(|s| s.cited_reference_ids.iter())
1133 .collect()
1134 }
1135
1136 /// Number of figures in the article body.
1137 pub fn figure_count(&self) -> usize {
1138 self.all_sections().map(|s| s.figures.len()).sum()
1139 }
1140
1141 /// Number of tables in the article body.
1142 pub fn table_count(&self) -> usize {
1143 self.all_sections().map(|s| s.tables.len()).sum()
1144 }
1145
1146 /// Concatenated plain text of the whole article body, in reading order.
1147 ///
1148 /// Section titles and paragraph content are joined with blank lines. Useful
1149 /// as a single input string for tokenizers, embeddings, or full-text search.
1150 pub fn body_text(&self) -> String {
1151 let mut out = String::new();
1152 for section in self.all_sections() {
1153 if let Some(title) = §ion.title
1154 && !title.trim().is_empty()
1155 {
1156 if !out.is_empty() {
1157 out.push_str("\n\n");
1158 }
1159 out.push_str(title);
1160 }
1161 if !section.content.trim().is_empty() {
1162 if !out.is_empty() {
1163 out.push_str("\n\n");
1164 }
1165 out.push_str(§ion.content);
1166 }
1167 }
1168 out
1169 }
1170
1171 /// All abstracts (a JATS article may have several, e.g. a main and a
1172 /// graphical abstract).
1173 pub fn abstracts(&self) -> &[Abstract] {
1174 &self.front.article_meta.abstracts
1175 }
1176
1177 /// Structured sections of the main (first) abstract, if it is structured.
1178 pub fn abstract_sections(&self) -> &[AbstractSection] {
1179 self.front
1180 .article_meta
1181 .abstracts
1182 .first()
1183 .map_or(&[], |a| a.sections.as_slice())
1184 }
1185
1186 /// References that carry a PubMed ID (useful for building citation graphs).
1187 pub fn references_with_pmid(&self) -> Vec<&Reference> {
1188 self.references().iter().filter(|r| r.has_pmid()).collect()
1189 }
1190
1191 /// References that carry a DOI.
1192 pub fn references_with_doi(&self) -> Vec<&Reference> {
1193 self.references().iter().filter(|r| r.has_doi()).collect()
1194 }
1195
1196 /// References whose `publication_type` equals `pub_type` (case-insensitive).
1197 pub fn references_by_type(&self, pub_type: &str) -> Vec<&Reference> {
1198 self.references()
1199 .iter()
1200 .filter(|r| {
1201 r.publication_type
1202 .as_ref()
1203 .is_some_and(|t| t.eq_ignore_ascii_case(pub_type))
1204 })
1205 .collect()
1206 }
1207
1208 /// Appendices (empty slice when the article has no back matter).
1209 pub fn appendices(&self) -> &[Section] {
1210 self.back.as_ref().map_or(&[], |b| b.appendices.as_slice())
1211 }
1212
1213 /// Glossary / abbreviation definitions (empty slice when absent).
1214 pub fn glossary(&self) -> &[Definition] {
1215 self.back.as_ref().map_or(&[], |b| b.glossary.as_slice())
1216 }
1217
1218 /// Supplementary materials attached to the article.
1219 pub fn supplementary_materials(&self) -> &[SupplementaryMaterial] {
1220 &self.supplementary_materials
1221 }
1222
1223 /// Data availability statement, if present.
1224 pub fn data_availability(&self) -> Option<&str> {
1225 self.data_availability.as_deref()
1226 }
1227
1228 /// Keyword groups (structured, with `kwd-group-type` / language).
1229 pub fn keyword_groups(&self) -> &[KeywordGroup] {
1230 &self.front.article_meta.keyword_groups
1231 }
1232
1233 /// Subject groups (structured, with `subj-group-type`).
1234 pub fn subject_groups(&self) -> &[SubjectGroup] {
1235 &self.front.article_meta.subject_groups
1236 }
1237
1238 /// Related articles (corrections, retractions, companions, …).
1239 pub fn related_articles(&self) -> &[RelatedArticle] {
1240 &self.front.article_meta.related_articles
1241 }
1242
1243 /// Articles corrected by this one (`related-article-type="corrected-article"`).
1244 pub fn corrections(&self) -> Vec<&RelatedArticle> {
1245 self.related_articles()
1246 .iter()
1247 .filter(|r| r.is_correction())
1248 .collect()
1249 }
1250
1251 /// Articles retracted by this one (`related-article-type="retracted-article"`).
1252 pub fn retractions(&self) -> Vec<&RelatedArticle> {
1253 self.related_articles()
1254 .iter()
1255 .filter(|r| r.is_retraction())
1256 .collect()
1257 }
1258
1259 /// Author notes (correspondence / contribution / present-address statements).
1260 pub fn author_notes(&self) -> &[String] {
1261 &self.front.article_meta.author_notes
1262 }
1263}
1264
1265// ============================================================================
1266// Tests
1267// ============================================================================
1268
1269#[cfg(test)]
1270mod tests {
1271 use super::*;
1272
1273 fn sec(section_type: Option<&str>, title: Option<&str>, content: &str) -> Section {
1274 Section {
1275 id: None,
1276 section_type: section_type.map(String::from),
1277 label: None,
1278 title: title.map(String::from),
1279 content: content.to_string(),
1280 subsections: Vec::new(),
1281 figures: Vec::new(),
1282 tables: Vec::new(),
1283 formulas: Vec::new(),
1284 cited_reference_ids: Vec::new(),
1285 }
1286 }
1287
1288 fn fig(id: &str) -> Figure {
1289 Figure {
1290 id: id.to_string(),
1291 label: None,
1292 caption: None,
1293 alt_text: None,
1294 fig_type: None,
1295 graphic_href: None,
1296 }
1297 }
1298
1299 #[test]
1300 fn test_section_kind_classification() {
1301 assert_eq!(
1302 SectionKind::from_sec_type(Some("intro")),
1303 SectionKind::Introduction
1304 );
1305 assert_eq!(
1306 SectionKind::from_sec_type(Some("Methods")),
1307 SectionKind::Methods
1308 );
1309 assert_eq!(
1310 SectionKind::from_sec_type(Some("materials|methods")),
1311 SectionKind::Methods
1312 );
1313 assert_eq!(
1314 SectionKind::from_sec_type(Some("results|discussion")),
1315 SectionKind::ResultsDiscussion
1316 );
1317 assert_eq!(SectionKind::from_sec_type(None), SectionKind::Unspecified);
1318 assert_eq!(
1319 SectionKind::from_sec_type(Some("custom-thing")),
1320 SectionKind::Other("custom-thing".to_string())
1321 );
1322 assert!(SectionKind::Methods.is_imrad());
1323 assert!(!SectionKind::SupplementaryMaterial.is_imrad());
1324 }
1325
1326 #[test]
1327 fn test_section_subtree_iteration_is_preorder() {
1328 // root -> [a -> [a1], b]
1329 let mut root = sec(Some("intro"), Some("Root"), "r");
1330 let mut a = sec(None, Some("A"), "a");
1331 a.subsections.push(sec(None, Some("A1"), "a1"));
1332 let b = sec(None, Some("B"), "b");
1333 root.subsections.push(a);
1334 root.subsections.push(b);
1335
1336 let titles: Vec<&str> = root
1337 .iter_subtree()
1338 .filter_map(|s| s.title.as_deref())
1339 .collect();
1340 assert_eq!(titles, vec!["Root", "A", "A1", "B"]);
1341 }
1342
1343 #[test]
1344 fn test_section_recursive_figures() {
1345 let mut root = sec(None, None, "");
1346 root.figures.push(fig("f1"));
1347 let mut child = sec(None, None, "");
1348 child.figures.push(fig("f2"));
1349 root.subsections.push(child);
1350
1351 let ids: Vec<&str> = root.all_figures().iter().map(|f| f.id.as_str()).collect();
1352 assert_eq!(ids, vec!["f1", "f2"]);
1353 assert_eq!(root.word_count(), 0);
1354 }
1355
1356 #[test]
1357 fn test_article_text_mining_accessors() {
1358 let mut intro = sec(Some("intro"), Some("Introduction"), "Hello world.");
1359 intro.figures.push(fig("f1"));
1360 let mut methods = sec(
1361 Some("methods"),
1362 Some("Statistical analysis"),
1363 "We used t-tests.",
1364 );
1365 let mut sub = sec(None, Some("Sub"), "Nested.");
1366 sub.tables.push(Table {
1367 id: "t1".to_string(),
1368 label: None,
1369 caption: None,
1370 head: vec![TableRow {
1371 cells: vec![TableCell {
1372 content: "h1".into(),
1373 is_header: true,
1374 colspan: Some(2),
1375 rowspan: None,
1376 }],
1377 }],
1378 body: vec![TableRow {
1379 cells: vec![
1380 TableCell {
1381 content: "a".into(),
1382 is_header: false,
1383 colspan: None,
1384 rowspan: None,
1385 },
1386 TableCell {
1387 content: "b".into(),
1388 is_header: false,
1389 colspan: None,
1390 rowspan: None,
1391 },
1392 ],
1393 }],
1394 footnotes: Vec::new(),
1395 });
1396 methods.subsections.push(sub);
1397
1398 let article = PmcArticle {
1399 article_type: None,
1400 front: Front {
1401 journal_meta: JournalMeta {
1402 title: None,
1403 abbreviation: None,
1404 issn_print: None,
1405 issn_electronic: None,
1406 publisher: None,
1407 },
1408 article_meta: ArticleMeta {
1409 pmcid: PmcId::from_u32(123),
1410 pmid: None,
1411 doi: None,
1412 categories: Vec::new(),
1413 title_group: TitleGroup {
1414 article_title: None,
1415 subtitle: None,
1416 },
1417 authors: Vec::new(),
1418 pub_dates: Vec::new(),
1419 volume: None,
1420 issue: None,
1421 fpage: None,
1422 lpage: None,
1423 elocation_id: None,
1424 history: Vec::new(),
1425 permissions: None,
1426 abstracts: vec![Abstract {
1427 abstract_type: None,
1428 text: "Full abstract.".to_string(),
1429 sections: vec![
1430 AbstractSection {
1431 label: Some("Background".into()),
1432 text: "bg".into(),
1433 },
1434 AbstractSection {
1435 label: Some("Methods".into()),
1436 text: "m".into(),
1437 },
1438 ],
1439 }],
1440 keywords: Vec::new(),
1441 keyword_groups: Vec::new(),
1442 subject_groups: Vec::new(),
1443 related_articles: Vec::new(),
1444 author_notes: Vec::new(),
1445 funding: Vec::new(),
1446 },
1447 },
1448 body: Some(Body {
1449 sections: vec![intro, methods],
1450 }),
1451 back: Some(Back {
1452 acknowledgments: None,
1453 conflict_of_interest: None,
1454 references: vec![
1455 Reference {
1456 id: "r1".into(),
1457 publication_type: Some("journal".into()),
1458 title: None,
1459 authors: Vec::new(),
1460 source: None,
1461 year: None,
1462 volume: None,
1463 issue: None,
1464 pages: None,
1465 elocation_id: None,
1466 editors: Vec::new(),
1467 publisher_name: None,
1468 publisher_loc: None,
1469 edition: None,
1470 isbn: None,
1471 conf_name: None,
1472 pmid: Some("111".into()),
1473 doi: None,
1474 },
1475 Reference {
1476 id: "r2".into(),
1477 publication_type: Some("book".into()),
1478 title: None,
1479 authors: Vec::new(),
1480 source: None,
1481 year: None,
1482 volume: None,
1483 issue: None,
1484 pages: None,
1485 elocation_id: None,
1486 editors: Vec::new(),
1487 publisher_name: None,
1488 publisher_loc: None,
1489 edition: None,
1490 isbn: None,
1491 conf_name: None,
1492 pmid: None,
1493 doi: Some("10.1/x".into()),
1494 },
1495 ],
1496 appendices: Vec::new(),
1497 glossary: Vec::new(),
1498 }),
1499 supplementary_materials: Vec::new(),
1500 data_availability: None,
1501 };
1502
1503 // Recursive section walk reaches nested subsection.
1504 assert_eq!(article.all_sections().count(), 3);
1505 // Filter by semantic kind and raw type.
1506 assert_eq!(article.sections_of_kind(&SectionKind::Methods).len(), 1);
1507 assert_eq!(article.sections_by_type("intro").len(), 1);
1508 // Title search is recursive + case-insensitive substring.
1509 assert!(article.find_section_by_title("statistical").is_some());
1510 // Recursive figure/table discovery.
1511 assert_eq!(article.figure_count(), 1);
1512 assert_eq!(article.table_count(), 1);
1513 // Body text joins titles and content.
1514 let body = article.body_text();
1515 assert!(body.contains("Hello world."));
1516 assert!(body.contains("We used t-tests."));
1517 assert!(body.contains("Nested."));
1518 // Abstract section lookup.
1519 let abs = &article.abstracts()[0];
1520 assert!(abs.is_structured());
1521 assert_eq!(abs.section_by_label("methods").unwrap().text, "m");
1522 assert_eq!(article.abstract_sections().len(), 2);
1523 // Reference filters.
1524 assert_eq!(article.references_with_pmid().len(), 1);
1525 assert_eq!(article.references_with_doi().len(), 1);
1526 assert_eq!(article.references_by_type("JOURNAL").len(), 1);
1527 // Table geometry helpers.
1528 let table = article.all_tables()[0];
1529 assert_eq!(table.row_count(), 2);
1530 assert_eq!(table.column_count(), 2);
1531 assert!(!table.is_empty());
1532 }
1533}