citum_engine/render/format.rs
1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Output format trait for pluggable renderers.
7
8use std::borrow::Cow;
9use std::ops::Range;
10
11use citum_schema::locale::GrammarOptions;
12use citum_schema::template::WrapPunctuation;
13
14/// Return Unicode quote marks for a nesting depth.
15///
16/// Even depths use outer double quotes; odd depths use inner single quotes.
17#[must_use]
18pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
19 if depth.is_multiple_of(2) {
20 ("\u{201C}", "\u{201D}")
21 } else {
22 ("\u{2018}", "\u{2019}")
23 }
24}
25
26/// Locale-resolved quote mark characters, threaded from
27/// [`GrammarOptions`](citum_schema::locale::GrammarOptions) through to rendering so that
28/// styles using non-English quotation conventions (e.g. fr-FR guillemets) render correctly.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct QuoteMarks {
31 /// Opening outer quotation mark.
32 pub open: String,
33 /// Closing outer quotation mark.
34 pub close: String,
35 /// Opening inner (nested) quotation mark.
36 pub open_inner: String,
37 /// Closing inner (nested) quotation mark.
38 pub close_inner: String,
39}
40
41impl QuoteMarks {
42 /// Return the opening and closing quote delimiters for a nesting depth.
43 ///
44 /// Depth 0 (and other even depths) use the outer pair; odd depths use the inner pair.
45 #[must_use]
46 pub fn for_depth(&self, depth: usize) -> (&str, &str) {
47 if depth.is_multiple_of(2) {
48 (&self.open, &self.close)
49 } else {
50 (&self.open_inner, &self.close_inner)
51 }
52 }
53}
54
55impl Default for QuoteMarks {
56 /// The historical hardcoded English fallback, used when no resolved locale is available.
57 fn default() -> Self {
58 let (open, close) = unicode_quote_marks(0);
59 let (open_inner, close_inner) = unicode_quote_marks(1);
60 Self {
61 open: open.to_string(),
62 close: close.to_string(),
63 open_inner: open_inner.to_string(),
64 close_inner: close_inner.to_string(),
65 }
66 }
67}
68
69impl From<&GrammarOptions> for QuoteMarks {
70 fn from(options: &GrammarOptions) -> Self {
71 Self {
72 open: options.open_quote.clone(),
73 close: options.close_quote.clone(),
74 open_inner: options.open_inner_quote.clone(),
75 close_inner: options.close_inner_quote.clone(),
76 }
77 }
78}
79
80/// Extra attributes applied to semantic wrappers when a renderer supports them.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct SemanticAttribute {
83 /// The attribute name.
84 pub name: &'static str,
85 /// The attribute value.
86 pub value: String,
87}
88
89/// Trait for defining how to render template components into a specific format.
90///
91/// Implementations of this trait define how various formatting instructions
92/// (emphasis, quotes, links, etc.) are translated into specific markup or text.
93pub trait OutputFormat: Default + Clone {
94 /// The type used for intermediate rendered content.
95 ///
96 /// For simple text formats, this is usually `String`. More complex formats
97 /// might use an AST or a specialized builder type.
98 type Output;
99
100 /// Convert a raw string into the format's output type.
101 ///
102 /// The implementation should handle any necessary character escaping
103 /// required by the target format.
104 fn text(&self, s: &str) -> Self::Output;
105
106 /// Join multiple outputs into a single output using a delimiter.
107 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
108
109 /// Convert the intermediate output into the final result string.
110 ///
111 /// This is called exactly once at the end of the rendering process
112 /// for a top-level component (citation or bibliography entry).
113 fn finish(&self, output: Self::Output) -> String;
114
115 /// Render content with emphasis (typically italics).
116 fn emph(&self, content: Self::Output) -> Self::Output;
117
118 /// Render content with strong emphasis (typically bold).
119 fn strong(&self, content: Self::Output) -> Self::Output;
120
121 /// Render content in small capitals.
122 fn small_caps(&self, content: Self::Output) -> Self::Output;
123
124 /// Render content as superscript text.
125 fn superscript(&self, content: Self::Output) -> Self::Output;
126
127 /// Return the opening and closing quote delimiters for a nesting depth.
128 ///
129 /// Depth 0 is an outer quote pair, depth 1 is the first inner quote pair,
130 /// and deeper levels alternate between those two pairs. `marks` carries the
131 /// locale-resolved quote characters; callers with no resolved locale can pass
132 /// `&QuoteMarks::default()` to keep the historical English fallback.
133 fn quote_marks<'a>(&self, depth: usize, marks: &'a QuoteMarks) -> (&'a str, &'a str) {
134 marks.for_depth(depth)
135 }
136
137 /// Render content enclosed in quotation marks at a specific nesting depth.
138 fn quote_with_depth(
139 &self,
140 content: Self::Output,
141 depth: usize,
142 marks: &QuoteMarks,
143 ) -> Self::Output {
144 let (open, close) = self.quote_marks(depth, marks);
145 self.affix(open, content, close)
146 }
147
148 /// Render content enclosed in outer quotation marks.
149 fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
150 self.quote_with_depth(content, 0, marks)
151 }
152
153 /// Apply outer prefix and suffix strings to the content.
154 ///
155 /// These are typically the "prefix" and "suffix" fields from the Citum style.
156 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
157
158 /// Apply inner prefix and suffix strings to the content.
159 ///
160 /// These are applied inside any wrapping punctuation.
161 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
162
163 /// Wrap the content in specific punctuation (parentheses, brackets, or quotes).
164 ///
165 /// `marks` supplies the locale-resolved quote characters for the `Quotes` variant.
166 fn wrap_punctuation(
167 &self,
168 wrap: &WrapPunctuation,
169 content: Self::Output,
170 marks: &QuoteMarks,
171 ) -> Self::Output;
172
173 /// Apply a semantic identifier (class) to the content.
174 ///
175 /// This is used for machine readability or fine-grained CSS styling.
176 /// Examples include "citum-title", "citum-author", "citum-doi".
177 fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
178
179 /// Render an annotation block.
180 ///
181 /// This is typically called at the end of a bibliography entry to render
182 /// reader-supplied notes.
183 fn annotation(&self, content: Self::Output) -> Self::Output;
184
185 // ── Block-level methods (used by the body markup renderer) ─────────────
186 // Defaults produce plain passthrough so existing format impls need not change.
187
188 /// Render a paragraph block.
189 fn paragraph(&self, content: Self::Output) -> Self::Output {
190 content
191 }
192
193 /// Render a block quotation.
194 fn block_quote(&self, content: Self::Output) -> Self::Output {
195 content
196 }
197
198 /// Render an unordered (bullet) list from pre-rendered item strings.
199 fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
200 self.join(items, "\n")
201 }
202
203 /// Render an ordered (numbered) list from pre-rendered item strings.
204 fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
205 self.join(items, "\n")
206 }
207
208 /// Render a list item.
209 fn list_item(&self, content: Self::Output) -> Self::Output {
210 content
211 }
212
213 /// Render a heading at the given level (1 = top-level).
214 fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
215 content
216 }
217
218 /// Render an unnumbered heading at the given level.
219 ///
220 /// Used for generated section headings (e.g. bibliography group
221 /// headings) that must not participate in document section numbering.
222 /// Defaults to [`Self::heading`]; formats with numbered headings
223 /// (LaTeX) override this with their unnumbered variants.
224 fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
225 self.heading(level, content)
226 }
227
228 /// Render a fenced or indented code block with an optional language hint.
229 ///
230 /// `content` is the raw (unescaped) code text.
231 fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
232 content
233 }
234
235 /// Render inline code.
236 fn inline_code(&self, content: Self::Output) -> Self::Output {
237 content
238 }
239
240 /// Render strikethrough text.
241 fn strikeout(&self, content: Self::Output) -> Self::Output {
242 content
243 }
244
245 /// Render a hard line break.
246 fn hard_break(&self) -> Self::Output {
247 self.text(" ")
248 }
249
250 /// Apply a semantic identifier plus optional attributes to the content.
251 ///
252 /// Formats that do not support extra attributes can ignore them and reuse
253 /// [`Self::semantic`].
254 fn semantic_with_attributes(
255 &self,
256 class: &str,
257 content: Self::Output,
258 _attributes: &[SemanticAttribute],
259 ) -> Self::Output {
260 self.semantic(class, content)
261 }
262
263 /// Render a full citation container with one or more reference IDs.
264 fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
265 content
266 }
267
268 // ── Visible-text methods ────────────────────────────────────────────────
269 // Used by bibliography/citation punctuation-boundary logic so separator
270 // and dedup decisions look at logical text, not backend markup (the
271 // "backends differ only in markup" rule — see DESIGN_PRINCIPLES §7).
272
273 /// Byte ranges of `fragment` that are visible (non-markup) text, in order.
274 ///
275 /// The default treats the whole fragment as visible, which is correct
276 /// for [`PlainText`](crate::render::plain::PlainText) and safe for any
277 /// third-party format that hasn't implemented a lexer: boundary logic
278 /// simply falls back to looking at raw characters, as it always has.
279 /// Backends whose inline methods (`emph`, `link`, `wrap_punctuation`,
280 /// ...) emit markup should override this to exclude it.
281 fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
282 let mut runs = Vec::new();
283 if !fragment.is_empty() {
284 runs.push(0..fragment.len());
285 }
286 runs
287 }
288
289 /// The visible (markup-stripped) text of a rendered fragment.
290 ///
291 /// Borrows `fragment` unchanged when it is entirely visible (the common
292 /// case); otherwise stitches the visible runs into an owned `String`.
293 fn visible_text<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
294 let runs = self.visible_runs(fragment);
295 if runs.len() == 1 && runs.first() == Some(&(0..fragment.len())) {
296 return Cow::Borrowed(fragment);
297 }
298 let mut owned = String::with_capacity(fragment.len());
299 for run in runs {
300 if let Some(slice) = fragment.get(run) {
301 owned.push_str(slice);
302 }
303 }
304 Cow::Owned(owned)
305 }
306
307 /// Hyperlink the content to a URL.
308 fn link(&self, url: &str, content: Self::Output) -> Self::Output;
309
310 /// Format a reference ID for use as a target or link (e.g. adding a prefix).
311 fn format_id(&self, id: &str) -> String {
312 id.to_string()
313 }
314
315 /// Render a full bibliography container.
316 ///
317 /// The default implementation joins the entries with double newlines.
318 fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
319 self.join(entries, "\n\n")
320 }
321
322 /// Render a single bibliography entry with its unique identifier and optional link.
323 ///
324 /// The default implementation just returns the content.
325 fn entry(
326 &self,
327 _id: &str,
328 content: Self::Output,
329 _url: Option<&str>,
330 _metadata: &ProcEntryMetadata,
331 ) -> Self::Output {
332 content
333 }
334}
335
336/// Metadata for a processed bibliography entry, used for interactivity.
337#[derive(Debug, Clone, Default, PartialEq)]
338pub struct ProcEntryMetadata {
339 /// Rendered primary author(s) string.
340 pub author: Option<String>,
341 /// Rendered year string.
342 pub year: Option<String>,
343 /// Rendered title string.
344 pub title: Option<String>,
345}
346
347#[cfg(test)]
348#[allow(
349 clippy::unwrap_used,
350 clippy::expect_used,
351 clippy::panic,
352 clippy::indexing_slicing,
353 clippy::todo,
354 clippy::unimplemented,
355 clippy::unreachable,
356 clippy::get_unwrap,
357 reason = "Panicking is acceptable and often desired in tests."
358)]
359mod tests {
360 use super::*;
361
362 #[derive(Default, Clone)]
363 struct DummyFormat;
364
365 impl OutputFormat for DummyFormat {
366 type Output = String;
367 fn text(&self, s: &str) -> Self::Output {
368 s.to_string()
369 }
370 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
371 items.join(delimiter)
372 }
373 fn finish(&self, output: Self::Output) -> String {
374 output
375 }
376 fn emph(&self, content: Self::Output) -> Self::Output {
377 format!("emph({content})")
378 }
379 fn strong(&self, content: Self::Output) -> Self::Output {
380 format!("strong({content})")
381 }
382 fn small_caps(&self, content: Self::Output) -> Self::Output {
383 format!("sc({content})")
384 }
385 fn superscript(&self, content: Self::Output) -> Self::Output {
386 format!("sup({content})")
387 }
388 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
389 format!("{prefix}{content}{suffix}")
390 }
391 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
392 format!("{prefix}{content}{suffix}")
393 }
394 fn wrap_punctuation(
395 &self,
396 _wrap: &WrapPunctuation,
397 content: Self::Output,
398 _marks: &QuoteMarks,
399 ) -> Self::Output {
400 content
401 }
402 fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
403 format!("sem[{class}]({content})")
404 }
405 fn annotation(&self, content: Self::Output) -> Self::Output {
406 format!("annot({content})")
407 }
408 fn link(&self, url: &str, content: Self::Output) -> Self::Output {
409 format!("link[{url}]({content})")
410 }
411 }
412
413 #[test]
414 fn test_default_methods() {
415 let fmt = DummyFormat;
416 assert_eq!(
417 fmt.semantic_with_attributes("test", "content".to_string(), &[]),
418 "sem[test](content)"
419 );
420 assert_eq!(
421 fmt.citation(vec!["id1".to_string()], "content".to_string()),
422 "content"
423 );
424 assert_eq!(fmt.format_id("id1"), "id1");
425 assert_eq!(
426 fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
427 "entry1\n\nentry2"
428 );
429 assert_eq!(
430 fmt.entry(
431 "id1",
432 "content".to_string(),
433 None,
434 &ProcEntryMetadata::default()
435 ),
436 "content"
437 );
438 }
439}