Skip to main content

meta_language/document_formatting/
mod.rs

1//! Shared, language-free document-formatting concept ontology.
2//!
3//! Documents in different container formats (Markdown, HTML, and — through the
4//! issues that build on this substrate — PDF and DOCX) express the *same*
5//! formatting concepts with different surface syntax. A Markdown `**bold**`, an
6//! HTML `<strong>bold</strong>`, and a DOCX run with the `<w:b/>` property all
7//! denote one language-free `strong` concept.
8//!
9//! This module seeds that concept set into a [`LinkNetwork`] with per-format
10//! syntax mappings, and provides data-driven resolution and reconstruction so
11//! the *same* concept link round-trips across formats. The per-format mapping
12//! is stored as a small template string whose `{}` placeholder marks the
13//! formatted content and whose `{name}` placeholders mark named attributes
14//! (`{href}`, `{src}`, `{lang}`) or the heading level (`{markers}` for the
15//! Markdown `#` run, `{level}` for the HTML digit).
16
17mod document;
18mod docx;
19mod opc;
20mod pdf;
21mod profile;
22
23pub use document::{BlockNode, FormattingDocument, InlineNode, parse_markup_document};
24pub use docx::{docx_profile_is_recognized, parse_docx_document, render_docx_document};
25pub use opc::{docx_package_is_recognized, parse_docx_package, render_docx_package};
26pub use pdf::{parse_pdf_document, pdf_profile_is_recognized, render_pdf_document};
27pub use profile::{
28    CROSS_FORMAT_CONCEPT_IDS, CROSS_FORMAT_CONCEPTS, DOCUMENT_FORMATS, canonical_document_format,
29    document_format_profile,
30};
31
32use std::collections::BTreeMap;
33
34use crate::link_network::{LinkId, LinkNetwork, LinkType};
35
36/// A single document-formatting concept with its per-format templates.
37struct FormattingConcept {
38    id: &'static str,
39    definition: &'static str,
40    /// `(language, template)` pairs. The template uses `{}` for content and
41    /// `{name}` for named holes; see the module documentation.
42    templates: &'static [(&'static str, &'static str)],
43}
44
45/// The shared document-formatting concept set required by issue #83.
46const DOCUMENT_FORMATTING_CONCEPTS: &[FormattingConcept] = &[
47    // --- inline concepts ---
48    FormattingConcept {
49        id: "emphasis",
50        definition: "Inline emphasis (italic) applied to a text fragment.",
51        templates: &[("Markdown", "*{}*"), ("HTML", "<em>{}</em>")],
52    },
53    FormattingConcept {
54        id: "strong-emphasis",
55        definition: "Inline strong importance (bold) applied to a text fragment.",
56        templates: &[("Markdown", "**{}**"), ("HTML", "<strong>{}</strong>")],
57    },
58    FormattingConcept {
59        id: "strikethrough",
60        definition: "Inline strikethrough (deleted) text fragment.",
61        templates: &[("Markdown", "~~{}~~"), ("HTML", "<del>{}</del>")],
62    },
63    FormattingConcept {
64        id: "inline-code",
65        definition: "Inline monospaced code span.",
66        templates: &[("Markdown", "`{}`"), ("HTML", "<code>{}</code>")],
67    },
68    FormattingConcept {
69        id: "hyperlink",
70        definition: "Inline hyperlink wrapping text and targeting a destination.",
71        templates: &[
72            ("Markdown", "[{}]({href})"),
73            ("HTML", "<a href=\"{href}\">{}</a>"),
74        ],
75    },
76    FormattingConcept {
77        id: "image",
78        definition: "Inline image with alternative text and a source reference.",
79        templates: &[
80            ("Markdown", "![{}]({src})"),
81            ("HTML", "<img src=\"{src}\" alt=\"{}\" />"),
82        ],
83    },
84    FormattingConcept {
85        id: "line-break",
86        definition: "Explicit inline line break inside a block.",
87        templates: &[("Markdown", "  \n"), ("HTML", "<br />")],
88    },
89    // --- block concepts ---
90    FormattingConcept {
91        id: "heading",
92        definition: "Section heading carrying a level from 1 (most significant) downward.",
93        templates: &[
94            ("Markdown", "{markers} {}"),
95            ("HTML", "<h{level}>{}</h{level}>"),
96        ],
97    },
98    FormattingConcept {
99        id: "paragraph",
100        definition: "Block of running text.",
101        templates: &[("Markdown", "{}"), ("HTML", "<p>{}</p>")],
102    },
103    FormattingConcept {
104        id: "block-quote",
105        definition: "Quoted block set off from the surrounding text.",
106        templates: &[
107            ("Markdown", "> {}"),
108            ("HTML", "<blockquote>{}</blockquote>"),
109        ],
110    },
111    FormattingConcept {
112        id: "bullet-list",
113        definition: "Unordered list container.",
114        templates: &[("Markdown", "{}"), ("HTML", "<ul>{}</ul>")],
115    },
116    FormattingConcept {
117        id: "ordered-list",
118        definition: "Ordered list container.",
119        templates: &[("Markdown", "{}"), ("HTML", "<ol>{}</ol>")],
120    },
121    FormattingConcept {
122        id: "list-item",
123        definition: "Single item within a list.",
124        templates: &[("Markdown", "- {}"), ("HTML", "<li>{}</li>")],
125    },
126    FormattingConcept {
127        id: "code-block",
128        definition: "Fenced block of preformatted code carrying an optional language.",
129        templates: &[
130            ("Markdown", "```{lang}\n{}\n```"),
131            (
132                "HTML",
133                "<pre><code class=\"language-{lang}\">{}</code></pre>",
134            ),
135        ],
136    },
137    FormattingConcept {
138        id: "thematic-break",
139        definition: "Thematic break (horizontal rule) between sections.",
140        templates: &[("Markdown", "---"), ("HTML", "<hr />")],
141    },
142    FormattingConcept {
143        id: "table",
144        definition: "Tabular data container.",
145        templates: &[("Markdown", "{}"), ("HTML", "<table>{}</table>")],
146    },
147    FormattingConcept {
148        id: "table-row",
149        definition: "Row within a table.",
150        templates: &[("Markdown", "{}"), ("HTML", "<tr>{}</tr>")],
151    },
152    FormattingConcept {
153        id: "table-cell",
154        definition: "Cell within a table row.",
155        templates: &[("Markdown", "{}"), ("HTML", "<td>{}</td>")],
156    },
157];
158
159/// Summary returned after seeding the document-formatting concept set.
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub struct DocumentFormattingSeedReport {
162    concepts: usize,
163    syntax_mappings: usize,
164}
165
166impl DocumentFormattingSeedReport {
167    /// Number of language-free formatting concepts seeded.
168    #[must_use]
169    pub const fn concepts(self) -> usize {
170        self.concepts
171    }
172
173    /// Number of per-format syntax mappings attached to the formatting concepts.
174    #[must_use]
175    pub const fn syntax_mappings(self) -> usize {
176        self.syntax_mappings
177    }
178}
179
180/// A formatting fragment resolved to its language-free concept.
181#[derive(Clone, Debug, PartialEq, Eq)]
182pub struct DocumentFormatMatch {
183    /// Exact concept id (for example `strong`).
184    pub concept: String,
185    /// Concept link in the network the fragment maps onto.
186    pub link: LinkId,
187    /// Formatted content captured from the `{}` placeholder.
188    pub content: String,
189    /// Heading level, when the concept carries one.
190    pub level: Option<u8>,
191    /// Named attribute captures (`href`, `src`, `lang`).
192    pub attributes: BTreeMap<String, String>,
193}
194
195/// A concept instance ready to be rendered into a target format.
196#[derive(Clone, Debug, Default, PartialEq, Eq)]
197pub struct DocumentFormatInstance {
198    /// Formatted content for the `{}` placeholder.
199    pub content: String,
200    /// Heading level, when the concept carries one.
201    pub level: Option<u8>,
202    /// Named attribute values (`href`, `src`, `lang`).
203    pub attributes: BTreeMap<String, String>,
204}
205
206impl DocumentFormatInstance {
207    /// Creates an instance carrying only formatted content.
208    #[must_use]
209    pub fn from_content(content: impl Into<String>) -> Self {
210        Self {
211            content: content.into(),
212            level: None,
213            attributes: BTreeMap::new(),
214        }
215    }
216
217    fn attribute(&self, name: &str) -> Option<&str> {
218        self.attributes.get(name).map(String::as_str)
219    }
220}
221
222/// A single segment of a compiled template.
223enum Segment {
224    Literal(String),
225    Hole(Hole),
226}
227
228/// The kind of value a template hole carries.
229#[derive(Clone, Copy, PartialEq, Eq)]
230enum Hole {
231    /// Formatted content (`{}`).
232    Content,
233    /// Markdown heading markers rendered as a run of `#`.
234    Markers,
235    /// HTML heading level rendered as a decimal digit.
236    Level,
237    /// Named attribute hole such as `href`, `src`, or `lang`.
238    Attribute(&'static str),
239}
240
241fn hole_for_name(name: &str) -> Hole {
242    match name {
243        "" => Hole::Content,
244        "markers" => Hole::Markers,
245        "level" => Hole::Level,
246        "href" => Hole::Attribute("href"),
247        "src" => Hole::Attribute("src"),
248        "lang" => Hole::Attribute("lang"),
249        other => panic!("unsupported document-formatting template hole {{{other}}}"),
250    }
251}
252
253/// Compiles a template string into ordered literal and hole segments.
254fn compile_template(template: &str) -> Vec<Segment> {
255    let mut segments = Vec::new();
256    let mut literal = String::new();
257    let mut chars = template.chars();
258
259    while let Some(character) = chars.next() {
260        if character == '{' {
261            if !literal.is_empty() {
262                segments.push(Segment::Literal(std::mem::take(&mut literal)));
263            }
264            let mut name = String::new();
265            for inner in chars.by_ref() {
266                if inner == '}' {
267                    break;
268                }
269                name.push(inner);
270            }
271            segments.push(Segment::Hole(hole_for_name(&name)));
272        } else {
273            literal.push(character);
274        }
275    }
276
277    if !literal.is_empty() {
278        segments.push(Segment::Literal(literal));
279    }
280
281    segments
282}
283
284/// Renders a compiled template from an instance, or `None` when a required
285/// attribute is missing.
286fn render_segments(segments: &[Segment], instance: &DocumentFormatInstance) -> Option<String> {
287    let mut output = String::new();
288    for segment in segments {
289        match segment {
290            Segment::Literal(text) => output.push_str(text),
291            Segment::Hole(Hole::Content) => output.push_str(&instance.content),
292            Segment::Hole(Hole::Markers) => {
293                let level = instance.level.unwrap_or(1).max(1);
294                output.push_str(&"#".repeat(usize::from(level)));
295            }
296            Segment::Hole(Hole::Level) => {
297                output.push_str(&instance.level.unwrap_or(1).max(1).to_string());
298            }
299            Segment::Hole(Hole::Attribute(name)) => output.push_str(instance.attribute(name)?),
300        }
301    }
302    Some(output)
303}
304
305/// Matches a fragment against a compiled template, capturing hole values.
306fn match_segments(segments: &[Segment], fragment: &str) -> Option<DocumentFormatInstance> {
307    let mut instance = DocumentFormatInstance::default();
308    let mut cursor = 0usize;
309
310    for (index, segment) in segments.iter().enumerate() {
311        match segment {
312            Segment::Literal(text) => {
313                if !fragment[cursor..].starts_with(text.as_str()) {
314                    return None;
315                }
316                cursor += text.len();
317            }
318            Segment::Hole(hole) => {
319                let rest = &fragment[cursor..];
320                let captured = match segments.get(index + 1) {
321                    Some(Segment::Literal(next)) => {
322                        let relative = rest.find(next.as_str())?;
323                        &rest[..relative]
324                    }
325                    // Two holes cannot sit next to each other in our templates.
326                    Some(Segment::Hole(_)) => return None,
327                    None => rest,
328                };
329                store_capture(&mut instance, *hole, captured)?;
330                cursor += captured.len();
331            }
332        }
333    }
334
335    if cursor == fragment.len() {
336        Some(instance)
337    } else {
338        None
339    }
340}
341
342fn store_capture(instance: &mut DocumentFormatInstance, hole: Hole, captured: &str) -> Option<()> {
343    match hole {
344        Hole::Content => instance.content = captured.to_string(),
345        Hole::Markers => {
346            if captured.is_empty() || !captured.bytes().all(|byte| byte == b'#') {
347                return None;
348            }
349            let level = u8::try_from(captured.len()).ok()?;
350            assign_level(instance, level)?;
351        }
352        Hole::Level => {
353            let level: u8 = captured.parse().ok()?;
354            assign_level(instance, level)?;
355        }
356        Hole::Attribute(name) => {
357            instance
358                .attributes
359                .insert(name.to_string(), captured.to_string());
360        }
361    }
362    Some(())
363}
364
365/// Stores a heading level, rejecting a fragment whose repeated level holes
366/// disagree (for example an HTML `<h2>...</h1>`).
367const fn assign_level(instance: &mut DocumentFormatInstance, level: u8) -> Option<()> {
368    match instance.level {
369        Some(existing) if existing != level => None,
370        _ => {
371            instance.level = Some(level);
372            Some(())
373        }
374    }
375}
376
377fn template_for(concept: &str, language: &str) -> Option<&'static str> {
378    let concept = crate::current_concept_id(concept);
379    DOCUMENT_FORMATTING_CONCEPTS
380        .iter()
381        .find(|entry| entry.id == concept)?
382        .templates
383        .iter()
384        .find(|(lang, _)| *lang == language)
385        .map(|(_, template)| *template)
386}
387
388/// Inline concepts are resolved most-specific-first so that, for example, an
389/// image is not mistaken for a hyperlink and bold is not mistaken for italic.
390const INLINE_RESOLUTION_ORDER: &[&str] = &[
391    "image",
392    "hyperlink",
393    "strong-emphasis",
394    "emphasis",
395    "strikethrough",
396    "inline-code",
397];
398
399impl LinkNetwork {
400    /// Seeds the shared document-formatting concept set with per-format syntax
401    /// mappings.
402    ///
403    /// Each concept becomes a language-free [`LinkType::Concept`] link, and each
404    /// `(language, template)` pair becomes a semantic syntax mapping so the same
405    /// concept reconstructs as `**…**` in Markdown, `<strong>…</strong>` in
406    /// HTML, and so on.
407    pub fn seed_document_formatting_concepts(&mut self) -> DocumentFormattingSeedReport {
408        let mut syntax_mappings = 0;
409        for concept in DOCUMENT_FORMATTING_CONCEPTS {
410            let concept_link = self.intern_concept(concept.id, Some(concept.definition));
411            self.insert_former_concept_ids(concept_link, concept.id);
412            for (language, template) in concept.templates {
413                self.insert_concept_syntax_mapping(
414                    concept_link,
415                    concept.id,
416                    language,
417                    template,
418                    true,
419                );
420                syntax_mappings += 1;
421            }
422        }
423
424        DocumentFormattingSeedReport {
425            concepts: DOCUMENT_FORMATTING_CONCEPTS.len(),
426            syntax_mappings,
427        }
428    }
429
430    /// Resolves a formatting `fragment` written in `language` to the shared,
431    /// language-free concept it denotes.
432    ///
433    /// Both Markdown `**bold**` and HTML `<strong>bold</strong>` resolve to the
434    /// one seeded `strong` concept link. Returns `None` when the fragment is not
435    /// a known formatting construct or the concept set has not been seeded.
436    #[must_use]
437    pub fn resolve_document_format(
438        &self,
439        language: &str,
440        fragment: &str,
441    ) -> Option<DocumentFormatMatch> {
442        for concept in INLINE_RESOLUTION_ORDER
443            .iter()
444            .copied()
445            .chain(DOCUMENT_FORMATTING_CONCEPTS.iter().map(|entry| entry.id))
446        {
447            let Some(template) = template_for(concept, language) else {
448                continue;
449            };
450            let segments = compile_template(template);
451            if let Some(instance) = match_segments(&segments, fragment) {
452                let Some(link) = self.find_term(concept) else {
453                    continue;
454                };
455                return Some(DocumentFormatMatch {
456                    concept: concept.to_string(),
457                    link,
458                    content: instance.content,
459                    level: instance.level,
460                    attributes: instance.attributes,
461                });
462            }
463        }
464        None
465    }
466
467    /// Renders a concept instance into `language` surface syntax.
468    ///
469    /// Returns `None` when the concept has no template for the language or a
470    /// required attribute is missing from the instance.
471    #[must_use]
472    pub fn render_document_format(
473        &self,
474        concept: &str,
475        language: &str,
476        instance: &DocumentFormatInstance,
477    ) -> Option<String> {
478        let template = template_for(concept, language)?;
479        render_segments(&compile_template(template), instance)
480    }
481
482    /// Translates a single formatting fragment from `source_language` to
483    /// `target_language` through the shared concept layer.
484    #[must_use]
485    pub fn translate_document_format(
486        &self,
487        source_language: &str,
488        target_language: &str,
489        fragment: &str,
490    ) -> Option<String> {
491        let resolved = self.resolve_document_format(source_language, fragment)?;
492        let instance = DocumentFormatInstance {
493            content: resolved.content,
494            level: resolved.level,
495            attributes: resolved.attributes,
496        };
497        self.render_document_format(&resolved.concept, target_language, &instance)
498    }
499
500    /// Returns the seeded concept link for a formatting concept id, when present.
501    #[must_use]
502    pub fn document_formatting_concept(&self, concept: &str) -> Option<LinkId> {
503        let _ = template_for(concept, "Markdown")?;
504        self.find_term(crate::current_concept_id(concept))
505            .filter(|link| self.is_concept_link(*link))
506    }
507
508    fn is_concept_link(&self, link: LinkId) -> bool {
509        self.link(link)
510            .is_some_and(|link| link.metadata().link_type() == Some(LinkType::Concept))
511    }
512}