1mod 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
36struct FormattingConcept {
38 id: &'static str,
39 definition: &'static str,
40 templates: &'static [(&'static str, &'static str)],
43}
44
45const DOCUMENT_FORMATTING_CONCEPTS: &[FormattingConcept] = &[
47 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", ""),
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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub struct DocumentFormattingSeedReport {
162 concepts: usize,
163 syntax_mappings: usize,
164}
165
166impl DocumentFormattingSeedReport {
167 #[must_use]
169 pub const fn concepts(self) -> usize {
170 self.concepts
171 }
172
173 #[must_use]
175 pub const fn syntax_mappings(self) -> usize {
176 self.syntax_mappings
177 }
178}
179
180#[derive(Clone, Debug, PartialEq, Eq)]
182pub struct DocumentFormatMatch {
183 pub concept: String,
185 pub link: LinkId,
187 pub content: String,
189 pub level: Option<u8>,
191 pub attributes: BTreeMap<String, String>,
193}
194
195#[derive(Clone, Debug, Default, PartialEq, Eq)]
197pub struct DocumentFormatInstance {
198 pub content: String,
200 pub level: Option<u8>,
202 pub attributes: BTreeMap<String, String>,
204}
205
206impl DocumentFormatInstance {
207 #[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
222enum Segment {
224 Literal(String),
225 Hole(Hole),
226}
227
228#[derive(Clone, Copy, PartialEq, Eq)]
230enum Hole {
231 Content,
233 Markers,
235 Level,
237 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
253fn 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
284fn 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
305fn 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 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
365const 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
388const INLINE_RESOLUTION_ORDER: &[&str] = &[
391 "image",
392 "hyperlink",
393 "strong-emphasis",
394 "emphasis",
395 "strikethrough",
396 "inline-code",
397];
398
399impl LinkNetwork {
400 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 #[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 #[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 #[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 #[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}