Skip to main content

lex_babel/formats/html/
mod.rs

1//! HTML format implementation
2//!
3//! This module implements bidirectional conversion between Lex and HTML5.
4//!
5//! # Library Choice
6//!
7//! We use the `html5ever` + `rcdom` + `markup5ever` ecosystem for HTML parsing and serialization:
8//! - `html5ever`: Browser-grade HTML5 parser from the Servo project
9//! - `markup5ever_rcdom`: Reference-counted DOM tree implementation
10//! - `markup5ever`: Serialization infrastructure
11//!
12//! This choice is based on:
13//! - Complete solution for both parsing and serialization
14//! - Battle-tested with 12M+ downloads
15//! - WHATWG HTML5 specification compliance
16//! - Active maintenance by Servo project
17//! - Handles malformed HTML gracefully
18//!
19//! # Element Mapping Table
20//!
21//! Complete Lex ↔ HTML Mapping:
22//!
23//! | Lex Element      | HTML Equivalent                                    | Export Notes                              | Import Notes                          |
24//! |------------------|----------------------------------------------------|-------------------------------------------|---------------------------------------|
25//! | Document         | `<div class="lex-document">`                       | Root container with document class        | Parse body content                    |
26//! | Session          | `<section class="lex-session lex-session-N">` + `<hN>` | Session → section + heading        | section + heading → Session           |
27//! | Paragraph        | `<p class="lex-paragraph">`                        | Direct mapping with class                 | Direct mapping                        |
28//! | List             | `<ul>`/`<ol>` with `class="lex-list"`              | Ordered/unordered preserved with class    | Detect ul/ol type                     |
29//! | ListItem         | `<li class="lex-list-item">`                       | Direct mapping with class                 | Direct mapping                        |
30//! | Definition       | `<dl class="lex-definition">` `<dt>` `<dd>`        | Term in dt, description in dd             | Parse dl/dt/dd structure              |
31//! | Verbatim         | `<pre class="lex-verbatim">` `<code>`              | Language → data-language attribute        | Extract language from attribute       |
32//! | Annotation       | `<!-- lex:label key=val -->`                       | HTML comment format                       | Parse HTML comment pattern            |
33//! | InlineContent:   |                                                    |                                           |                                       |
34//! |   Text           | Plain text                                         | Direct                                    | Direct                                |
35//! |   Bold           | `<strong>`                                         | Semantic strong tag                       | Parse both strong and b               |
36//! |   Italic         | `<em>`                                             | Semantic emphasis tag                     | Parse both em and i                   |
37//! |   Code           | `<code>`                                           | Inline code tag                           | Direct                                |
38//! |   Math           | `<span class="lex-math">`                          | Preserve $ delimiters in span             | Parse math span                       |
39//! |   Reference      | `<a href="url">text</a>`                           | Convert to anchor with prev word as text  | Parse anchor back to reference        |
40//!
41//! # CSS Classes
42//!
43//! All Lex elements receive CSS classes matching their AST structure:
44//! - `.lex-document`: Root document container
45//! - `.lex-session`, `.lex-session-1`, `.lex-session-2`, etc.: Sessions with depth
46//! - `.lex-paragraph`: Paragraphs
47//! - `.lex-list`: Lists (combined with ul/ol)
48//! - `.lex-list-item`: List items
49//! - `.lex-definition`: Definition lists
50//! - `.lex-verbatim`: Verbatim/code blocks
51//! - `.lex-math`: Math expressions
52//!
53//! This enables:
54//! - Precise CSS targeting for presentation
55//! - Perfect round-trip conversion (HTML → Lex → HTML preserves structure)
56//! - Custom theming without modifying structure
57//!
58//! # CSS and Theming
59//!
60//! HTML export includes embedded CSS from:
61//! - `css/baseline.css`: Browser reset + default modern presentation (always included)
62//! - `css/themes/theme-*.css`: Optional overrides layered on top of the baseline
63//!
64//! The default theme (`HtmlTheme::Modern`) injects an empty stylesheet so the
65//! baseline alone controls rendering. Other themes, like Fancy Serif, only add
66//! targeted overrides.
67//!
68//! Themes use Google Fonts and are mobile-responsive.
69//!
70//! # Output Format
71//!
72//! Export produces a single, self-contained HTML file:
73//! - Complete HTML5 document structure
74//! - Embedded CSS in <style> tag
75//! - No external dependencies (except optionally-linked fonts)
76//! - Mobile-responsive viewport meta tag
77//!
78//! # Lossy Conversions
79//!
80//! The following conversions may lose information on round-trip:
81//! - Lex sessions beyond level 6 → h6 with nested sections (HTML heading limit)
82//! - Lex annotations → HTML comments (exported but parsing is lossy)
83//! - Some whitespace normalization
84//!
85//! # Architecture Notes
86//!
87//! Like the Markdown implementation, we handle the nested-to-flat conversion using the IR
88//! event system (lex-babel/src/common/). HTML is more naturally hierarchical than Markdown,
89//! but sessions still require special handling as they don't map directly to HTML's heading
90//! structure.
91//!
92//! We use semantic HTML elements with CSS classes for styling rather than presentational
93//! elements.
94//!
95//! # Implementation Status
96//!
97//! - [x] Export (Lex → HTML)
98//!   - [ ] Document structure with CSS embedding
99//!   - [ ] Paragraph
100//!   - [ ] Heading (Session) → section + heading
101//!   - [ ] Bold, Italic, Code inlines
102//!   - [ ] Lists - ordered/unordered
103//!   - [ ] Code blocks (Verbatim) with language attribute
104//!   - [ ] Definitions → dl/dt/dd
105//!   - [ ] Annotations → HTML comments
106//!   - [ ] Math → span with class
107//!   - [ ] References → anchors with link conversion
108//! - [ ] Import (HTML → Lex)
109//!   - [ ] All elements (to be implemented after export)
110
111mod serializer;
112
113use crate::error::FormatError;
114use crate::format::Format;
115use lex_core::lex::ast::Document;
116use std::fs;
117
118pub use serializer::{
119    serialize_to_html, serialize_to_html_with_options, serialize_to_html_with_registry,
120    HtmlExportOutcome, HtmlOptions,
121};
122
123/// Returns the default baseline CSS used for HTML export.
124///
125/// This is the same CSS embedded in all HTML exports when no custom CSS is provided.
126/// Use this to get a starting point for custom styling.
127pub fn get_default_css() -> &'static str {
128    include_str!("../../../css/baseline.css")
129}
130
131/// Format implementation for HTML
132pub struct HtmlFormat {
133    /// CSS theme to use for export
134    theme: HtmlTheme,
135}
136
137/// Available CSS themes for HTML export
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub enum HtmlTheme {
140    /// Serif typography override (fonts only, layout comes from baseline)
141    FancySerif,
142    /// Baseline modern theme (no-op; relies on baseline.css)
143    #[default]
144    Modern,
145}
146
147impl Default for HtmlFormat {
148    fn default() -> Self {
149        Self::new(HtmlTheme::Modern)
150    }
151}
152
153impl HtmlFormat {
154    /// Create a new HTML format with the specified theme
155    pub fn new(theme: HtmlTheme) -> Self {
156        Self { theme }
157    }
158
159    /// Create HTML format with fancy serif theme
160    pub fn with_fancy_serif() -> Self {
161        Self::new(HtmlTheme::FancySerif)
162    }
163
164    /// Create HTML format with modern theme
165    pub fn with_modern() -> Self {
166        Self::new(HtmlTheme::Modern)
167    }
168}
169
170impl Format for HtmlFormat {
171    fn name(&self) -> &str {
172        "html"
173    }
174
175    fn description(&self) -> &str {
176        "HTML5 format with embedded CSS"
177    }
178
179    fn file_extensions(&self) -> &[&str] {
180        &["html", "htm"]
181    }
182
183    fn supports_parsing(&self) -> bool {
184        false // Implement after export is working
185    }
186
187    fn supports_serialization(&self) -> bool {
188        true
189    }
190
191    fn parse(&self, _source: &str) -> Result<Document, FormatError> {
192        Err(FormatError::NotSupported(
193            "HTML import not yet implemented".to_string(),
194        ))
195    }
196
197    fn serialize(&self, doc: &Document) -> Result<String, FormatError> {
198        serializer::serialize_to_html(doc, self.theme)
199    }
200
201    fn serialize_with_options(
202        &self,
203        doc: &Document,
204        options: &std::collections::HashMap<String, String>,
205    ) -> Result<crate::format::SerializedDocument, FormatError> {
206        let mut theme = self.theme;
207        if let Some(theme_str) = options.get("theme") {
208            theme = match theme_str.as_str() {
209                "fancy-serif" => HtmlTheme::FancySerif,
210                "modern" | "default" => HtmlTheme::Modern,
211                _ => {
212                    // Fallback to default for unknown themes, or could error.
213                    // For now, let's fallback to Modern to be safe.
214                    HtmlTheme::Modern
215                }
216            };
217        }
218
219        let mut html_options = HtmlOptions::new(theme);
220
221        // Handle custom CSS option (expects CSS content, not path)
222        if let Some(css_content) = options.get("custom_css") {
223            html_options = html_options.with_custom_css(css_content.clone());
224        } else if let Some(css_path) = options.get("css-path").or_else(|| options.get("css_path")) {
225            let css = fs::read_to_string(css_path).map_err(|err| {
226                FormatError::SerializationError(format!(
227                    "Failed to read CSS at '{css_path}': {err}"
228                ))
229            })?;
230            html_options = html_options.with_custom_css(css);
231        }
232
233        serializer::serialize_to_html_with_options(doc, html_options)
234            .map(crate::format::SerializedDocument::Text)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::format::SerializedDocument;
242    use lex_core::lex::ast::Document;
243    use std::collections::HashMap;
244    use std::io::Write;
245    use tempfile::NamedTempFile;
246
247    #[test]
248    fn test_get_default_css_returns_baseline() {
249        let css = get_default_css();
250        // Should contain key selectors from baseline.css
251        assert!(css.contains(".lex-document"));
252        assert!(css.contains(".lex-paragraph"));
253        assert!(css.contains(".lex-session"));
254        // Should be non-trivial content
255        assert!(css.len() > 1000);
256    }
257
258    #[test]
259    fn test_get_default_css_is_same_as_embedded() {
260        // The CSS returned should be the exact same as what's embedded in HTML output
261        let css = get_default_css();
262        // Verify it's the actual include_str content by checking for CSS custom properties
263        assert!(css.contains("--lex-"));
264    }
265
266    #[test]
267    fn test_baseline_css_has_paged_media_rules() {
268        // Regression test for #607: PDF export (and any paged-media renderer)
269        // needs page numbers and break-control rules.
270        let css = get_default_css();
271
272        assert!(
273            css.contains("@page"),
274            "baseline should define @page rules for paged-media output"
275        );
276        assert!(
277            css.contains("counter(page)"),
278            "baseline should emit page-number counter"
279        );
280        assert!(
281            css.contains("break-inside") || css.contains("page-break-inside"),
282            "baseline should declare break-inside rules"
283        );
284        assert!(
285            css.contains("dt {") || css.contains("dt{"),
286            "baseline should style dt for break-after avoid"
287        );
288    }
289
290    #[test]
291    fn test_baseline_css_keeps_verbatim_blocks_unsplit() {
292        // Follow-up to #607: every other paged-media element pairs the
293        // legacy `page-break-*` property with its modern `break-*`
294        // companion so Chromium's headless paged-media (which now treats
295        // `page-break-inside` as deprecated) keeps honoring the rule.
296        // The `.lex-verbatim` class (emitted on every `<pre>` from a
297        // verbatim block) only had the legacy form, so code blocks could
298        // still split across pages.
299        let css = get_default_css();
300        // The paged-media `.lex-verbatim` rule lives inside the
301        // `@media print` block. Locate the print block, then assert
302        // both companion properties are present on `.lex-verbatim`
303        // within it.
304        let print_idx = css
305            .find("@media print")
306            .expect("baseline should declare an @media print block");
307        let print_block = &css[print_idx..];
308        let verbatim_in_print = print_block
309            .find(".lex-verbatim")
310            .expect("@media print should style .lex-verbatim");
311        let snippet =
312            &print_block[verbatim_in_print..(verbatim_in_print + 250).min(print_block.len())];
313        assert!(
314            snippet.contains("break-inside: avoid"),
315            "@media print .lex-verbatim should declare modern `break-inside: avoid[-page]`: {snippet}"
316        );
317        assert!(
318            snippet.contains("page-break-inside: avoid"),
319            "@media print .lex-verbatim should retain legacy `page-break-inside: avoid`: {snippet}"
320        );
321    }
322
323    #[test]
324    fn test_css_path_option_loads_file() {
325        let mut temp = NamedTempFile::new().expect("failed to create temp file");
326        writeln!(temp, ".from-path {{ color: blue; }}").expect("failed to write temp css");
327
328        let doc = Document::new();
329        let format = HtmlFormat::default();
330        let mut options = HashMap::new();
331        options.insert(
332            "css-path".to_string(),
333            temp.path().to_string_lossy().to_string(),
334        );
335
336        let html = format
337            .serialize_with_options(&doc, &options)
338            .expect("html export should succeed");
339
340        let SerializedDocument::Text(content) = html else {
341            panic!("expected text html output");
342        };
343        assert!(content.contains(".from-path { color: blue; }"));
344    }
345
346    #[test]
347    fn test_css_path_option_errors_on_missing_file() {
348        let doc = Document::new();
349        let format = HtmlFormat::default();
350        let mut options = HashMap::new();
351        options.insert("css-path".to_string(), "/no/such/file.css".to_string());
352
353        let err = match format.serialize_with_options(&doc, &options) {
354            Ok(_) => panic!("expected css-path lookup to fail"),
355            Err(err) => err,
356        };
357
358        match err {
359            FormatError::SerializationError(msg) => {
360                assert!(msg.contains("/no/such/file.css"));
361            }
362            other => panic!("expected serialization error, got {other:?}"),
363        }
364    }
365}