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) = &section.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(&section.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}