From e919490d68a762fc0f2b2ca59645b3d04f1cc7a8 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Wed, 4 Mar 2026 23:14:22 +0900 Subject: [PATCH 1/4] feat: add tab stop parsing and improved tab rendering Parse custom tab stops from DOCX paragraph properties (position, alignment, leader type) and render tabs as Typst #h() spacing instead of raw tab characters. Falls back to Word's default 36pt (0.5 inch) spacing when no tab stops are defined. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/ir/style.rs | 33 +++++ crates/office2pdf/src/parser/docx.rs | 61 +++++++- crates/office2pdf/src/render/typst_gen.rs | 164 +++++++++++++++++++++- 3 files changed, 250 insertions(+), 8 deletions(-) diff --git a/crates/office2pdf/src/ir/style.rs b/crates/office2pdf/src/ir/style.rs index a134a49f..c1e67f33 100644 --- a/crates/office2pdf/src/ir/style.rs +++ b/crates/office2pdf/src/ir/style.rs @@ -28,6 +28,39 @@ pub struct ParagraphStyle { pub heading_level: Option, /// Text direction for bidirectional rendering (RTL for Arabic/Hebrew). pub direction: Option, + /// Custom tab stop positions for this paragraph. + pub tab_stops: Option>, +} + +/// A custom tab stop definition. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TabStop { + /// Position in points from the left margin. + pub position: f64, + /// Alignment of text at this tab stop. + pub alignment: TabAlignment, + /// Leader character filling the space before this tab stop. + pub leader: TabLeader, +} + +/// Tab stop alignment type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TabAlignment { + #[default] + Left, + Center, + Right, + Decimal, +} + +/// Leader character for a tab stop. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TabLeader { + #[default] + None, + Dot, + Hyphen, + Underscore, } /// Text direction for bidirectional (BiDi) rendering. diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index 538f8d9c..d41f90dc 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -9,11 +9,12 @@ use crate::error::{ConvertError, ConvertWarning}; /// truncated to prevent stack overflow on pathological documents. const MAX_TABLE_DEPTH: usize = 64; use crate::ir::{ - Alignment, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, Color, - ColumnLayout, Document, FloatingImage, FlowPage, HFInline, HeaderFooter, HeaderFooterParagraph, - ImageData, ImageFormat, LineSpacing, List, ListItem, ListKind, Margins, MathEquation, Page, - PageSize, Paragraph, ParagraphStyle, Run, StyleSheet, Table, TableCell, TableRow, - TextDirection, TextStyle, VerticalTextAlign, WrapMode, + Alignment, Block, BorderLineStyle, BorderSide, CellBorder, Chart, Color, ColumnLayout, + Document, FloatingImage, FlowPage, HFInline, HeaderFooter, HeaderFooterParagraph, ImageData, + ImageFormat, LineSpacing, List, ListItem, ListKind, Margins, MathEquation, Page, PageSize, + Paragraph, ParagraphStyle, Run, StyleSheet, TabAlignment, TabLeader, TabStop, Table, + TableCell, TableRow, TextDirection, TextStyle, VerticalTextAlign, WrapMode, + CellVerticalAlign, }; use crate::parser::Parser; @@ -253,6 +254,7 @@ fn merge_paragraph_style( .and_then(|s| s.heading_level) .map(|lvl| (lvl + 1) as u8), direction: explicit.direction, + tab_stops: explicit.tab_stops.clone().or(style_para.tab_stops.clone()), } } @@ -1706,6 +1708,8 @@ fn extract_paragraph_style(prop: &docx_rs::ParagraphProperty) -> ParagraphStyle let (line_spacing, space_before, space_after) = extract_line_spacing(&prop.line_spacing); + let tab_stops = extract_tab_stops(&prop.tabs); + ParagraphStyle { alignment, indent_left, @@ -1716,6 +1720,7 @@ fn extract_paragraph_style(prop: &docx_rs::ParagraphProperty) -> ParagraphStyle space_after, heading_level: None, direction: None, // Set by BidiContext after style merge + tab_stops, } } @@ -1775,6 +1780,52 @@ fn extract_line_spacing( (line_spacing, space_before, space_after) } +/// Extract tab stops from paragraph properties. +/// docx-rs Tab has `pos` in twips and `val`/`leader` as enums. +fn extract_tab_stops(tabs: &[docx_rs::Tab]) -> Option> { + if tabs.is_empty() { + return None; + } + + let mut stops: Vec = + tabs.iter() + .filter_map(|t| { + let pos_twips = t.pos? as f64; + let position = pos_twips / 20.0; // twips → points + + let alignment = match t.val { + Some(docx_rs::TabValueType::Center) => TabAlignment::Center, + Some(docx_rs::TabValueType::Right) | Some(docx_rs::TabValueType::End) => { + TabAlignment::Right + } + Some(docx_rs::TabValueType::Decimal) => TabAlignment::Decimal, + Some(docx_rs::TabValueType::Clear) => return None, // "clear" removes a tab stop + _ => TabAlignment::Left, + }; + + let leader = + match t.leader { + Some(docx_rs::TabLeaderType::Dot) + | Some(docx_rs::TabLeaderType::MiddleDot) => TabLeader::Dot, + Some(docx_rs::TabLeaderType::Hyphen) + | Some(docx_rs::TabLeaderType::Heavy) => TabLeader::Hyphen, + Some(docx_rs::TabLeaderType::Underscore) => TabLeader::Underscore, + _ => TabLeader::None, + }; + + Some(TabStop { + position, + alignment, + leader, + }) + }) + .collect(); + + stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); + + if stops.is_empty() { None } else { Some(stops) } +} + #[allow(clippy::too_many_arguments)] /// Convert a docx-rs Table to an IR Table. /// diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 7731feca..4c57637d 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -9,7 +9,9 @@ use crate::ir::{ Color, ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, FlowPage, GradientFill, HFInline, HeaderFooter, ImageData, ImageFormat, LineSpacing, List, ListKind, Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, - Shadow, Shape, ShapeKind, SmartArt, Table, TableCell, TablePage, TextDirection, TextStyle, + ShapeKind, SmartArt, TabStop, Table, TableCell, TablePage, TextDirection, TextStyle, + Shadow, Shape, ShapeKind, SmartArt, TabStop, Table, TableCell, TablePage, TextDirection, + TextStyle, VerticalTextAlign, WrapMode, }; @@ -1684,9 +1686,17 @@ fn generate_paragraph(out: &mut String, para: &Paragraph) -> Result<(), ConvertE let _ = write!(out, "#align({align_str})["); } - // Generate runs + // Generate runs, replacing tab characters with #h() spacing + let mut tab_index: usize = 0; + let mut tab_cursor_pt: f64 = 0.0; for run in ¶.runs { - generate_run(out, run); + generate_run_with_tabs( + out, + run, + style.tab_stops.as_deref(), + &mut tab_index, + &mut tab_cursor_pt, + ); } if use_align { @@ -1741,6 +1751,67 @@ fn write_par_settings(out: &mut String, style: &ParagraphStyle) { } } +/// Word's default tab stop interval (0.5 inch = 36pt). +const DEFAULT_TAB_WIDTH_PT: f64 = 36.0; + +/// Generate a run, replacing tab characters with `#h()` spacing based on tab stops. +fn generate_run_with_tabs( + out: &mut String, + run: &Run, + tab_stops: Option<&[TabStop]>, + tab_index: &mut usize, + tab_cursor_pt: &mut f64, +) { + if !run.text.contains('\t') { + generate_run(out, run); + return; + } + + // Split text at tab boundaries and emit #h() for each tab + let parts: Vec<&str> = run.text.split('\t').collect(); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + // Emit tab spacing + let width: f64 = resolve_tab_advance(tab_stops, tab_index, tab_cursor_pt); + let _ = write!(out, "#h({}pt)", format_f64(width)); + } + if !part.is_empty() { + let sub_run = Run { + text: part.to_string(), + style: run.style.clone(), + href: run.href.clone(), + footnote: None, + }; + generate_run(out, &sub_run); + } + } +} + +/// Resolve tab width as a relative advance from the current tab cursor. +fn resolve_tab_advance( + tab_stops: Option<&[TabStop]>, + tab_index: &mut usize, + tab_cursor_pt: &mut f64, +) -> f64 { + let width: f64 = tab_stops + .and_then(|stops| { + if *tab_index >= stops.len() { + return None; + } + let stop_position = stops[*tab_index].position; + *tab_index += 1; + if stop_position > *tab_cursor_pt { + Some(stop_position - *tab_cursor_pt) + } else { + None + } + }) + .unwrap_or(DEFAULT_TAB_WIDTH_PT); + + *tab_cursor_pt += width; + width +} + fn generate_run(out: &mut String, run: &Run) { // Emit footnote if present (footnote runs have empty text) if let Some(ref content) = run.footnote { @@ -2279,6 +2350,93 @@ mod tests { ); } + #[test] + fn test_generate_tab_with_default_spacing() { + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle::default(), + runs: vec![Run { + text: "Name:\tValue".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("Name:") && result.contains("#h(36pt)") && result.contains("Value"), + "Expected default tab spacing in: {result}" + ); + } + + #[test] + fn test_generate_tab_with_custom_tab_stops() { + use crate::ir::{TabAlignment, TabLeader, TabStop}; + + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + tab_stops: Some(vec![ + TabStop { + position: 72.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: 216.0, + alignment: TabAlignment::Right, + leader: TabLeader::Dot, + }, + ]), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "Col1\tCol2\tCol3".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("#h(72pt)"), + "Expected first tab stop at 72pt in: {result}" + ); + assert!( + result.contains("#h(144pt)"), + "Expected second tab to advance by 144pt to the 216pt stop in: {result}" + ); + } + + #[test] + fn test_generate_tab_exceeds_defined_stops() { + use crate::ir::{TabAlignment, TabLeader, TabStop}; + + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + tab_stops: Some(vec![TabStop { + position: 100.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }]), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "A\tB\tC".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("#h(100pt)"), + "Expected first tab at 100pt in: {result}" + ); + assert!( + result.contains("#h(36pt)"), + "Expected fallback tab at 36pt in: {result}" + ); + } + #[test] fn test_generate_multiple_paragraphs() { let doc = make_doc(vec![make_flow_page(vec![ From 17c1474eac31a460bb341bc14997df890bee28cd Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 7 Mar 2026 01:03:26 +0900 Subject: [PATCH 2/4] fix: measure tab stops against rendered width Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/docx.rs | 52 +++- crates/office2pdf/src/render/typst_gen.rs | 316 ++++++++++++++++------ 2 files changed, 284 insertions(+), 84 deletions(-) diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index d41f90dc..b602125f 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -1823,7 +1823,11 @@ fn extract_tab_stops(tabs: &[docx_rs::Tab]) -> Option> { stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); - if stops.is_empty() { None } else { Some(stops) } + if stops.is_empty() { + Some(vec![]) + } else { + Some(stops) + } } #[allow(clippy::too_many_arguments)] @@ -6185,6 +6189,52 @@ mod tests { ); } + #[test] + fn test_extract_tab_stops_preserves_explicit_clear_override() { + let tabs = vec![ + docx_rs::Tab::new() + .val(docx_rs::TabValueType::Clear) + .pos(1440), + ]; + + let tab_stops = extract_tab_stops(&tabs); + + assert_eq!( + tab_stops, + Some(vec![]), + "A paragraph-level clear tab must remain an explicit empty override" + ); + } + + #[test] + fn test_merge_paragraph_style_allows_clearing_inherited_tab_stops() { + let inherited = TabStop { + position: 72.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }; + let explicit = ParagraphStyle { + tab_stops: Some(vec![]), + ..ParagraphStyle::default() + }; + let style = ResolvedStyle { + text: TextStyle::default(), + paragraph: ParagraphStyle { + tab_stops: Some(vec![inherited]), + ..ParagraphStyle::default() + }, + heading_level: None, + }; + + let merged = merge_paragraph_style(&explicit, Some(&style)); + + assert_eq!( + merged.tab_stops, + Some(vec![]), + "Explicit paragraph tab clearing must override inherited style tab stops" + ); + } + // ── BiDi / RTL tests ────────────────────────────────────────────── /// Helper: create a bidi paragraph with the given text. diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 4c57637d..3b77fae1 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -5,14 +5,12 @@ use unicode_normalization::UnicodeNormalization; use crate::config::ConvertOptions; use crate::error::ConvertError; use crate::ir::{ - Alignment, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, ChartType, - Color, ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, - FlowPage, GradientFill, HFInline, HeaderFooter, ImageData, ImageFormat, LineSpacing, List, - ListKind, Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, - ShapeKind, SmartArt, TabStop, Table, TableCell, TablePage, TextDirection, TextStyle, - Shadow, Shape, ShapeKind, SmartArt, TabStop, Table, TableCell, TablePage, TextDirection, - TextStyle, - VerticalTextAlign, WrapMode, + Alignment, Block, BorderLineStyle, BorderSide, CellBorder, Chart, ChartType, Color, + ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, FlowPage, + GradientFill, HFInline, HeaderFooter, ImageData, ImageFormat, LineSpacing, List, ListKind, + Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, Shadow, + Shape, ShapeKind, SmartArt, TabAlignment, TabStop, Table, TableCell, TablePage, + TextDirection, TextStyle, VerticalTextAlign, WrapMode, CellVerticalAlign, }; /// An image asset to be embedded in the Typst compilation. @@ -1550,9 +1548,7 @@ fn generate_cell_content( /// Generate paragraph content for inside a table cell (runs only, no block wrapper). fn generate_cell_paragraph(out: &mut String, para: &Paragraph) { - for run in ¶.runs { - generate_run(out, run); - } + generate_runs_with_tabs(out, ¶.runs, para.style.tab_stops.as_deref()); } fn generate_image(out: &mut String, img: &ImageData, ctx: &mut GenCtx) { @@ -1652,9 +1648,7 @@ fn generate_paragraph(out: &mut String, para: &Paragraph) -> Result<(), ConvertE // Heading paragraphs: emit #heading(level: N)[content] for proper PDF structure tagging if let Some(level) = style.heading_level { let _ = write!(out, "#heading(level: {level})["); - for run in ¶.runs { - generate_run(out, run); - } + generate_runs_with_tabs(out, ¶.runs, style.tab_stops.as_deref()); out.push_str("]\n"); return Ok(()); } @@ -1686,18 +1680,7 @@ fn generate_paragraph(out: &mut String, para: &Paragraph) -> Result<(), ConvertE let _ = write!(out, "#align({align_str})["); } - // Generate runs, replacing tab characters with #h() spacing - let mut tab_index: usize = 0; - let mut tab_cursor_pt: f64 = 0.0; - for run in ¶.runs { - generate_run_with_tabs( - out, - run, - style.tab_stops.as_deref(), - &mut tab_index, - &mut tab_cursor_pt, - ); - } + generate_runs_with_tabs(out, ¶.runs, style.tab_stops.as_deref()); if use_align { out.push(']'); @@ -1754,62 +1737,213 @@ fn write_par_settings(out: &mut String, style: &ParagraphStyle) { /// Word's default tab stop interval (0.5 inch = 36pt). const DEFAULT_TAB_WIDTH_PT: f64 = 36.0; -/// Generate a run, replacing tab characters with `#h()` spacing based on tab stops. -fn generate_run_with_tabs( - out: &mut String, - run: &Run, - tab_stops: Option<&[TabStop]>, - tab_index: &mut usize, - tab_cursor_pt: &mut f64, -) { - if !run.text.contains('\t') { - generate_run(out, run); +fn generate_runs_with_tabs(out: &mut String, runs: &[Run], tab_stops: Option<&[TabStop]>) { + if !paragraph_contains_tabs(runs) { + generate_runs(out, runs); return; } - // Split text at tab boundaries and emit #h() for each tab - let parts: Vec<&str> = run.text.split('\t').collect(); - for (i, part) in parts.iter().enumerate() { - if i > 0 { - // Emit tab spacing - let width: f64 = resolve_tab_advance(tab_stops, tab_index, tab_cursor_pt); - let _ = write!(out, "#h({}pt)", format_f64(width)); + let segments: Vec> = split_runs_on_tabs(runs); + out.push_str("#context {\n"); + + for (index, segment) in segments.iter().enumerate() { + let _ = write!(out, " let tab_segment_{index} = ["); + generate_runs(out, segment); + out.push_str("]\n"); + + if index == 0 { + out.push_str(" let tab_prefix_0 = tab_segment_0\n"); + continue; + } + + let _ = writeln!( + out, + " let tab_prefix_width_{index} = measure(tab_prefix_{}).width", + index - 1 + ); + let _ = writeln!( + out, + " let tab_segment_width_{index} = measure(tab_segment_{index}).width" + ); + + if let Some(anchor_runs) = extract_decimal_anchor_runs(segment) { + let _ = write!(out, " let tab_decimal_anchor_{index} = ["); + generate_runs(out, &anchor_runs); + out.push_str("]\n"); + let _ = writeln!( + out, + " let tab_decimal_width_{index} = measure(tab_decimal_anchor_{index}).width" + ); } - if !part.is_empty() { - let sub_run = Run { - text: part.to_string(), + + let _ = writeln!( + out, + " let tab_default_remainder_{index} = calc.rem-euclid(tab_prefix_width_{index}.abs.pt(), {})", + format_f64(DEFAULT_TAB_WIDTH_PT) + ); + let _ = writeln!( + out, + " let tab_advance_{index} = {}", + build_tab_advance_expr(index, segment, tab_stops) + ); + let _ = writeln!( + out, + " let tab_prefix_{index} = [#tab_prefix_{}#h(tab_advance_{index})#tab_segment_{index}]", + index - 1 + ); + } + + let _ = writeln!(out, " tab_prefix_{}", segments.len() - 1); + out.push('}'); +} + +fn paragraph_contains_tabs(runs: &[Run]) -> bool { + runs.iter().any(|run| run.text.contains('\t')) +} + +fn generate_runs(out: &mut String, runs: &[Run]) { + for run in runs { + generate_run(out, run); + } +} + +fn split_runs_on_tabs(runs: &[Run]) -> Vec> { + let mut segments: Vec> = vec![Vec::new()]; + + for run in runs { + if run.footnote.is_some() || !run.text.contains('\t') { + if run.footnote.is_some() || !run.text.is_empty() { + segments + .last_mut() + .expect("split_runs_on_tabs should always have a segment") + .push(run.clone()); + } + continue; + } + + for (index, part) in run.text.split('\t').enumerate() { + if index > 0 { + segments.push(Vec::new()); + } + + if !part.is_empty() { + segments + .last_mut() + .expect("split_runs_on_tabs should always have a segment") + .push(Run { + text: part.to_string(), + style: run.style.clone(), + href: run.href.clone(), + footnote: None, + }); + } + } + } + + segments +} + +fn extract_decimal_anchor_runs(runs: &[Run]) -> Option> { + let mut anchor_runs: Vec = Vec::new(); + + for run in runs { + if let Some(content) = &run.footnote { + anchor_runs.push(Run { + text: String::new(), + style: run.style.clone(), + href: run.href.clone(), + footnote: Some(content.clone()), + }); + continue; + } + + let Some((offset, _)) = run + .text + .char_indices() + .find(|(_, ch)| matches!(ch, '.' | ',')) + else { + if !run.text.is_empty() { + anchor_runs.push(run.clone()); + } + continue; + }; + + if offset > 0 { + anchor_runs.push(Run { + text: run.text[..offset].to_string(), style: run.style.clone(), href: run.href.clone(), footnote: None, - }; - generate_run(out, &sub_run); + }); } + + return Some(anchor_runs); } + + None } -/// Resolve tab width as a relative advance from the current tab cursor. -fn resolve_tab_advance( - tab_stops: Option<&[TabStop]>, - tab_index: &mut usize, - tab_cursor_pt: &mut f64, -) -> f64 { - let width: f64 = tab_stops - .and_then(|stops| { - if *tab_index >= stops.len() { - return None; - } - let stop_position = stops[*tab_index].position; - *tab_index += 1; - if stop_position > *tab_cursor_pt { - Some(stop_position - *tab_cursor_pt) - } else { - None - } - }) - .unwrap_or(DEFAULT_TAB_WIDTH_PT); +fn build_tab_advance_expr(index: usize, segment: &[Run], tab_stops: Option<&[TabStop]>) -> String { + let prefix_width_var = format!("tab_prefix_width_{index}"); + let segment_width_var = format!("tab_segment_width_{index}"); + let decimal_width_var = + extract_decimal_anchor_runs(segment).map(|_| format!("tab_decimal_width_{index}")); + let default_expr = build_default_tab_advance_expr(index); + + let Some(tab_stops) = tab_stops else { + return default_expr; + }; - *tab_cursor_pt += width; - width + if tab_stops.is_empty() { + return default_expr; + } + + let mut expr = String::new(); + for (stop_index, stop) in tab_stops.iter().enumerate() { + let branch = format!( + "calc.max(0pt, {}pt - {prefix_width_var} - {})", + format_f64(stop.position), + tab_alignment_offset_expr(stop, &segment_width_var, decimal_width_var.as_deref()) + ); + + if stop_index == 0 { + let _ = write!( + expr, + "if {prefix_width_var} < {}pt {{ {branch} }}", + format_f64(stop.position) + ); + } else { + let _ = write!( + expr, + " else if {prefix_width_var} < {}pt {{ {branch} }}", + format_f64(stop.position) + ); + } + } + + let _ = write!(expr, " else {{ {default_expr} }}"); + expr +} + +fn build_default_tab_advance_expr(index: usize) -> String { + format!( + "if tab_default_remainder_{index} == 0 {{ {}pt }} else {{ ({} - tab_default_remainder_{index}) * 1pt }}", + format_f64(DEFAULT_TAB_WIDTH_PT), + format_f64(DEFAULT_TAB_WIDTH_PT) + ) +} + +fn tab_alignment_offset_expr( + stop: &TabStop, + segment_width_var: &str, + decimal_width_var: Option<&str>, +) -> String { + match stop.alignment { + TabAlignment::Left => "0pt".to_string(), + TabAlignment::Center => format!("{segment_width_var} / 2"), + TabAlignment::Right => segment_width_var.to_string(), + TabAlignment::Decimal => decimal_width_var.unwrap_or(segment_width_var).to_string(), + } } fn generate_run(out: &mut String, run: &Run) { @@ -2351,7 +2485,7 @@ mod tests { } #[test] - fn test_generate_tab_with_default_spacing() { + fn test_generate_tab_uses_measured_default_stops() { let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { style: ParagraphStyle::default(), runs: vec![Run { @@ -2363,13 +2497,25 @@ mod tests { })])]); let result = generate_typst(&doc).unwrap().source; assert!( - result.contains("Name:") && result.contains("#h(36pt)") && result.contains("Value"), - "Expected default tab spacing in: {result}" + result.contains("#context {"), + "Expected contextual tab rendering in: {result}" + ); + assert!( + result.contains("measure(tab_prefix_0).width"), + "Expected tab spacing to measure the rendered prefix in: {result}" + ); + assert!( + result.contains("calc.rem-euclid(tab_prefix_width_1.abs.pt(), 36)"), + "Expected default tabs to advance to the next 36pt stop in: {result}" + ); + assert!( + !result.contains("#h(36pt)"), + "Expected default tabs to avoid a hard-coded 36pt gap in: {result}" ); } #[test] - fn test_generate_tab_with_custom_tab_stops() { + fn test_generate_tab_uses_next_explicit_stop_and_alignment() { use crate::ir::{TabAlignment, TabLeader, TabStop}; let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { @@ -2397,17 +2543,21 @@ mod tests { })])]); let result = generate_typst(&doc).unwrap().source; assert!( - result.contains("#h(72pt)"), - "Expected first tab stop at 72pt in: {result}" + result.contains("if tab_prefix_width_1 < 72pt"), + "Expected the first explicit stop to be chosen by measured width in: {result}" + ); + assert!( + result.contains("else if tab_prefix_width_2 < 216pt"), + "Expected the next explicit stop to be selected after the first one in: {result}" ); assert!( - result.contains("#h(144pt)"), - "Expected second tab to advance by 144pt to the 216pt stop in: {result}" + result.contains("216pt - tab_prefix_width_2 - tab_segment_width_2"), + "Expected right-aligned tabs to subtract the following segment width in: {result}" ); } #[test] - fn test_generate_tab_exceeds_defined_stops() { + fn test_generate_tab_falls_back_to_next_default_stop_after_explicit_tabs() { use crate::ir::{TabAlignment, TabLeader, TabStop}; let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { @@ -2428,12 +2578,12 @@ mod tests { })])]); let result = generate_typst(&doc).unwrap().source; assert!( - result.contains("#h(100pt)"), - "Expected first tab at 100pt in: {result}" + result.contains("if tab_prefix_width_1 < 100pt"), + "Expected the explicit stop to be used when it is still ahead of the prefix in: {result}" ); assert!( - result.contains("#h(36pt)"), - "Expected fallback tab at 36pt in: {result}" + result.contains("calc.rem-euclid(tab_prefix_width_2.abs.pt(), 36)"), + "Expected tabs beyond explicit stops to use the next default stop in: {result}" ); } From 70785a539e98485972f2db184091a169cc7228a5 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 7 Mar 2026 01:09:49 +0900 Subject: [PATCH 3/4] style: format tab stop fixes after rebase Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/docx.rs | 9 ++++----- crates/office2pdf/src/render/typst_gen.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index b602125f..1c53a260 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -9,12 +9,11 @@ use crate::error::{ConvertError, ConvertWarning}; /// truncated to prevent stack overflow on pathological documents. const MAX_TABLE_DEPTH: usize = 64; use crate::ir::{ - Alignment, Block, BorderLineStyle, BorderSide, CellBorder, Chart, Color, ColumnLayout, - Document, FloatingImage, FlowPage, HFInline, HeaderFooter, HeaderFooterParagraph, ImageData, - ImageFormat, LineSpacing, List, ListItem, ListKind, Margins, MathEquation, Page, PageSize, - Paragraph, ParagraphStyle, Run, StyleSheet, TabAlignment, TabLeader, TabStop, Table, + Alignment, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, Color, + ColumnLayout, Document, FloatingImage, FlowPage, HFInline, HeaderFooter, HeaderFooterParagraph, + ImageData, ImageFormat, LineSpacing, List, ListItem, ListKind, Margins, MathEquation, Page, + PageSize, Paragraph, ParagraphStyle, Run, StyleSheet, TabAlignment, TabLeader, TabStop, Table, TableCell, TableRow, TextDirection, TextStyle, VerticalTextAlign, WrapMode, - CellVerticalAlign, }; use crate::parser::Parser; diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 3b77fae1..8dec599c 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -5,12 +5,12 @@ use unicode_normalization::UnicodeNormalization; use crate::config::ConvertOptions; use crate::error::ConvertError; use crate::ir::{ - Alignment, Block, BorderLineStyle, BorderSide, CellBorder, Chart, ChartType, Color, - ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, FlowPage, - GradientFill, HFInline, HeaderFooter, ImageData, ImageFormat, LineSpacing, List, ListKind, - Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, Shadow, - Shape, ShapeKind, SmartArt, TabAlignment, TabStop, Table, TableCell, TablePage, - TextDirection, TextStyle, VerticalTextAlign, WrapMode, CellVerticalAlign, + Alignment, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, ChartType, + Color, ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, + FlowPage, GradientFill, HFInline, HeaderFooter, ImageData, ImageFormat, LineSpacing, List, + ListKind, Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, + Shadow, Shape, ShapeKind, SmartArt, TabAlignment, TabStop, Table, TableCell, TablePage, + TextDirection, TextStyle, VerticalTextAlign, WrapMode, }; /// An image asset to be embedded in the Typst compilation. From 8cfdb1a8d3701c0039da880d882dd46d88ee99a7 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 7 Mar 2026 02:17:47 +0900 Subject: [PATCH 4/4] fix: handle docx tab stop inheritance correctly Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/lib.rs | 35 +++ crates/office2pdf/src/parser/docx.rs | 284 +++++++++++++++++++--- crates/office2pdf/src/render/typst_gen.rs | 199 ++++++++++++++- 3 files changed, 475 insertions(+), 43 deletions(-) diff --git a/crates/office2pdf/src/lib.rs b/crates/office2pdf/src/lib.rs index 1d5299fb..6d4cf114 100644 --- a/crates/office2pdf/src/lib.rs +++ b/crates/office2pdf/src/lib.rs @@ -495,6 +495,41 @@ mod tests { assert!(pdf.starts_with(b"%PDF")); } + #[test] + fn test_render_document_with_tab_leader() { + let doc = Document { + metadata: Metadata::default(), + pages: vec![Page::Flow(FlowPage { + size: PageSize::default(), + margins: Margins::default(), + content: vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + tab_stops: Some(vec![TabStop { + position: 144.0, + alignment: TabAlignment::Left, + leader: TabLeader::Dot, + }]), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "Heading\t12".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })], + header: None, + footer: None, + columns: None, + })], + styles: StyleSheet::default(), + }; + + let pdf = render_document(&doc).unwrap(); + assert!(!pdf.is_empty()); + assert!(pdf.starts_with(b"%PDF")); + } + #[test] fn test_render_document_styled_text() { let doc = Document { diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index 1c53a260..a1a020f1 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -110,10 +110,17 @@ fn extract_num_info(para: &docx_rs::Paragraph) -> Option { struct ResolvedStyle { text: TextStyle, paragraph: ParagraphStyle, + paragraph_tab_overrides: Option>, /// Heading level from outline_lvl (0 = Heading 1, 1 = Heading 2, ..., 5 = Heading 6). heading_level: Option, } +#[derive(Debug, Clone, Copy, PartialEq)] +enum TabStopOverride { + Set(TabStop), + Clear(f64), +} + /// Map from style_id → resolved formatting. type StyleMap = HashMap; @@ -133,6 +140,7 @@ fn build_style_map(styles: &docx_rs::Styles) -> StyleMap { let text = extract_run_style(&style.run_property); let paragraph = extract_paragraph_style(&style.paragraph_property); + let paragraph_tab_overrides = extract_tab_stop_overrides(&style.paragraph_property.tabs); let heading_level = style .paragraph_property .outline_lvl @@ -145,6 +153,7 @@ fn build_style_map(styles: &docx_rs::Styles) -> StyleMap { ResolvedStyle { text, paragraph, + paragraph_tab_overrides, heading_level, }, ); @@ -233,28 +242,106 @@ fn merge_text_style(explicit: &TextStyle, style: Option<&ResolvedStyle>) -> Text /// Explicit formatting takes priority. fn merge_paragraph_style( explicit: &ParagraphStyle, + explicit_tab_overrides: Option<&[TabStopOverride]>, style: Option<&ResolvedStyle>, ) -> ParagraphStyle { - let style_para = match style { - Some(s) => &s.paragraph, - None => return explicit.clone(), - }; + let style_para = style.map(|s| &s.paragraph); + let inherited_tab_stops = style.and_then(resolve_style_tab_stops); ParagraphStyle { - alignment: explicit.alignment.or(style_para.alignment), - indent_left: explicit.indent_left.or(style_para.indent_left), - indent_right: explicit.indent_right.or(style_para.indent_right), - indent_first_line: explicit.indent_first_line.or(style_para.indent_first_line), - line_spacing: explicit.line_spacing.or(style_para.line_spacing), - space_before: explicit.space_before.or(style_para.space_before), - space_after: explicit.space_after.or(style_para.space_after), + alignment: explicit.alignment.or(style_para.and_then(|s| s.alignment)), + indent_left: explicit + .indent_left + .or(style_para.and_then(|s| s.indent_left)), + indent_right: explicit + .indent_right + .or(style_para.and_then(|s| s.indent_right)), + indent_first_line: explicit + .indent_first_line + .or(style_para.and_then(|s| s.indent_first_line)), + line_spacing: explicit + .line_spacing + .or(style_para.and_then(|s| s.line_spacing)), + space_before: explicit + .space_before + .or(style_para.and_then(|s| s.space_before)), + space_after: explicit + .space_after + .or(style_para.and_then(|s| s.space_after)), // Heading level from the style definition (outline_lvl 0→H1, 1→H2, ...) heading_level: style .and_then(|s| s.heading_level) .map(|lvl| (lvl + 1) as u8), direction: explicit.direction, - tab_stops: explicit.tab_stops.clone().or(style_para.tab_stops.clone()), + tab_stops: merge_tab_stops( + explicit.tab_stops.as_deref(), + explicit_tab_overrides, + inherited_tab_stops.as_deref(), + ), + } +} + +fn resolve_style_tab_stops(style: &ResolvedStyle) -> Option> { + resolve_tab_stop_source( + style.paragraph.tab_stops.as_deref(), + style.paragraph_tab_overrides.as_deref(), + ) +} + +fn resolve_tab_stop_source( + tab_stops: Option<&[TabStop]>, + tab_overrides: Option<&[TabStopOverride]>, +) -> Option> { + if let Some(tab_overrides) = tab_overrides { + let mut resolved: Vec = Vec::new(); + apply_tab_stop_overrides(&mut resolved, tab_overrides); + return Some(resolved); + } + + tab_stops.map(|tab_stops| tab_stops.to_vec()) +} + +fn merge_tab_stops( + explicit_tab_stops: Option<&[TabStop]>, + explicit_tab_overrides: Option<&[TabStopOverride]>, + inherited_tab_stops: Option<&[TabStop]>, +) -> Option> { + if let Some(explicit_tab_overrides) = explicit_tab_overrides { + let mut resolved: Vec = inherited_tab_stops.unwrap_or(&[]).to_vec(); + apply_tab_stop_overrides(&mut resolved, explicit_tab_overrides); + return Some(resolved); + } + + explicit_tab_stops + .map(|explicit_tab_stops| explicit_tab_stops.to_vec()) + .or_else(|| inherited_tab_stops.map(|inherited_tab_stops| inherited_tab_stops.to_vec())) +} + +fn apply_tab_stop_overrides(tab_stops: &mut Vec, tab_overrides: &[TabStopOverride]) { + for tab_override in tab_overrides { + match tab_override { + TabStopOverride::Set(tab_stop) => { + tab_stops.retain(|existing| { + !tab_stop_positions_match(existing.position, tab_stop.position) + }); + tab_stops.push(*tab_stop); + } + TabStopOverride::Clear(position) => { + tab_stops + .retain(|existing| !tab_stop_positions_match(existing.position, *position)); + } + } } + + tab_stops.sort_by(|left, right| { + left.position + .partial_cmp(&right.position) + .unwrap_or(std::cmp::Ordering::Equal) + }); +} + +fn tab_stop_positions_match(left: f64, right: f64) -> bool { + (left - right).abs() < 0.01 } /// Look up the pStyle reference from a paragraph's property. @@ -1244,7 +1331,9 @@ fn extract_docx_footer(section_prop: &docx_rs::SectionProperty) -> Option
HeaderFooterParagraph { - let style = extract_paragraph_style(¶.property); + let explicit_style = extract_paragraph_style(¶.property); + let explicit_tab_overrides = extract_tab_stop_overrides(¶.property.tabs); + let style = merge_paragraph_style(&explicit_style, explicit_tab_overrides.as_deref(), None); let mut elements: Vec = Vec::new(); for child in ¶.children { @@ -1546,7 +1635,13 @@ fn convert_paragraph_blocks( if !runs.is_empty() { out.append(&mut inline_images); let explicit_para_style = extract_paragraph_style(¶.property); - let mut style = merge_paragraph_style(&explicit_para_style, resolved_style); + let explicit_tab_overrides = + extract_tab_stop_overrides(¶.property.tabs); + let mut style = merge_paragraph_style( + &explicit_para_style, + explicit_tab_overrides.as_deref(), + resolved_style, + ); if is_rtl { style.direction = Some(TextDirection::Rtl); } @@ -1621,7 +1716,12 @@ fn convert_paragraph_blocks( out.extend(inline_images); let explicit_para_style = extract_paragraph_style(¶.property); - let mut style = merge_paragraph_style(&explicit_para_style, resolved_style); + let explicit_tab_overrides = extract_tab_stop_overrides(¶.property.tabs); + let mut style = merge_paragraph_style( + &explicit_para_style, + explicit_tab_overrides.as_deref(), + resolved_style, + ); if is_rtl { style.direction = Some(TextDirection::Rtl); } @@ -1782,28 +1882,37 @@ fn extract_line_spacing( /// Extract tab stops from paragraph properties. /// docx-rs Tab has `pos` in twips and `val`/`leader` as enums. fn extract_tab_stops(tabs: &[docx_rs::Tab]) -> Option> { + let tab_overrides = extract_tab_stop_overrides(tabs)?; + let mut tab_stops: Vec = Vec::new(); + apply_tab_stop_overrides(&mut tab_stops, &tab_overrides); + Some(tab_stops) +} + +fn extract_tab_stop_overrides(tabs: &[docx_rs::Tab]) -> Option> { if tabs.is_empty() { return None; } - let mut stops: Vec = + Some( tabs.iter() - .filter_map(|t| { - let pos_twips = t.pos? as f64; - let position = pos_twips / 20.0; // twips → points + .filter_map(|tab| { + let position = tab.pos.map(|pos_twips| pos_twips as f64 / 20.0)?; + + if matches!(tab.val, Some(docx_rs::TabValueType::Clear)) { + return Some(TabStopOverride::Clear(position)); + } - let alignment = match t.val { + let alignment = match tab.val { Some(docx_rs::TabValueType::Center) => TabAlignment::Center, Some(docx_rs::TabValueType::Right) | Some(docx_rs::TabValueType::End) => { TabAlignment::Right } Some(docx_rs::TabValueType::Decimal) => TabAlignment::Decimal, - Some(docx_rs::TabValueType::Clear) => return None, // "clear" removes a tab stop _ => TabAlignment::Left, }; let leader = - match t.leader { + match tab.leader { Some(docx_rs::TabLeaderType::Dot) | Some(docx_rs::TabLeaderType::MiddleDot) => TabLeader::Dot, Some(docx_rs::TabLeaderType::Hyphen) @@ -1812,21 +1921,14 @@ fn extract_tab_stops(tabs: &[docx_rs::Tab]) -> Option> { _ => TabLeader::None, }; - Some(TabStop { + Some(TabStopOverride::Set(TabStop { position, alignment, leader, - }) + })) }) - .collect(); - - stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); - - if stops.is_empty() { - Some(vec![]) - } else { - Some(stops) - } + .collect(), + ) } #[allow(clippy::too_many_arguments)] @@ -6205,6 +6307,119 @@ mod tests { ); } + #[test] + fn test_merge_paragraph_style_preserves_inherited_tabs_not_overridden() { + let explicit_prop = docx_rs::ParagraphProperty::new().add_tab( + docx_rs::Tab::new() + .val(docx_rs::TabValueType::Left) + .pos(2160), + ); + let explicit = extract_paragraph_style(&explicit_prop); + let explicit_tab_overrides = extract_tab_stop_overrides(&explicit_prop.tabs); + let style = ResolvedStyle { + text: TextStyle::default(), + paragraph: ParagraphStyle { + tab_stops: Some(vec![ + TabStop { + position: 72.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: 144.0, + alignment: TabAlignment::Right, + leader: TabLeader::Dot, + }, + ]), + ..ParagraphStyle::default() + }, + paragraph_tab_overrides: None, + heading_level: None, + }; + + let merged = + merge_paragraph_style(&explicit, explicit_tab_overrides.as_deref(), Some(&style)); + + assert_eq!( + merged.tab_stops, + Some(vec![ + TabStop { + position: 72.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: 108.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: 144.0, + alignment: TabAlignment::Right, + leader: TabLeader::Dot, + }, + ]), + "Paragraph-level tabs should extend inherited style tabs instead of replacing them" + ); + } + + #[test] + fn test_merge_paragraph_style_clears_only_targeted_inherited_tab_stop() { + let explicit_prop = docx_rs::ParagraphProperty::new() + .add_tab( + docx_rs::Tab::new() + .val(docx_rs::TabValueType::Clear) + .pos(2880), + ) + .add_tab( + docx_rs::Tab::new() + .val(docx_rs::TabValueType::Left) + .pos(2160), + ); + let explicit = extract_paragraph_style(&explicit_prop); + let explicit_tab_overrides = extract_tab_stop_overrides(&explicit_prop.tabs); + let style = ResolvedStyle { + text: TextStyle::default(), + paragraph: ParagraphStyle { + tab_stops: Some(vec![ + TabStop { + position: 72.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: 144.0, + alignment: TabAlignment::Right, + leader: TabLeader::Dot, + }, + ]), + ..ParagraphStyle::default() + }, + paragraph_tab_overrides: None, + heading_level: None, + }; + + let merged = + merge_paragraph_style(&explicit, explicit_tab_overrides.as_deref(), Some(&style)); + + assert_eq!( + merged.tab_stops, + Some(vec![ + TabStop { + position: 72.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: 108.0, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + ]), + "A clear tab should remove only the matching inherited stop, not the whole inherited list" + ); + } + #[test] fn test_merge_paragraph_style_allows_clearing_inherited_tab_stops() { let inherited = TabStop { @@ -6222,10 +6437,11 @@ mod tests { tab_stops: Some(vec![inherited]), ..ParagraphStyle::default() }, + paragraph_tab_overrides: None, heading_level: None, }; - let merged = merge_paragraph_style(&explicit, Some(&style)); + let merged = merge_paragraph_style(&explicit, None, Some(&style)); assert_eq!( merged.tab_stops, diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 8dec599c..cb2746fd 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -9,8 +9,8 @@ use crate::ir::{ Color, ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, FlowPage, GradientFill, HFInline, HeaderFooter, ImageData, ImageFormat, LineSpacing, List, ListKind, Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, - Shadow, Shape, ShapeKind, SmartArt, TabAlignment, TabStop, Table, TableCell, TablePage, - TextDirection, TextStyle, VerticalTextAlign, WrapMode, + Shadow, Shape, ShapeKind, SmartArt, TabAlignment, TabLeader, TabStop, Table, TableCell, + TablePage, TextDirection, TextStyle, VerticalTextAlign, WrapMode, }; /// An image asset to be embedded in the Typst compilation. @@ -1788,7 +1788,12 @@ fn generate_runs_with_tabs(out: &mut String, runs: &[Run], tab_stops: Option<&[T ); let _ = writeln!( out, - " let tab_prefix_{index} = [#tab_prefix_{}#h(tab_advance_{index})#tab_segment_{index}]", + " let tab_fill_{index} = {}", + build_tab_fill_expr(index, tab_stops) + ); + let _ = writeln!( + out, + " let tab_prefix_{index} = [#tab_prefix_{}#tab_fill_{index}#tab_segment_{index}]", index - 1 ); } @@ -1844,7 +1849,15 @@ fn split_runs_on_tabs(runs: &[Run]) -> Vec> { } fn extract_decimal_anchor_runs(runs: &[Run]) -> Option> { + let visible_text: String = runs + .iter() + .filter(|run| run.footnote.is_none()) + .map(|run| run.text.as_str()) + .collect(); + let separator_offset = find_decimal_separator_offset(&visible_text)?; + let mut anchor_runs: Vec = Vec::new(); + let mut visible_offset: usize = 0; for run in runs { if let Some(content) = &run.footnote { @@ -1857,17 +1870,16 @@ fn extract_decimal_anchor_runs(runs: &[Run]) -> Option> { continue; } - let Some((offset, _)) = run - .text - .char_indices() - .find(|(_, ch)| matches!(ch, '.' | ',')) - else { + let run_end = visible_offset + run.text.len(); + if run_end <= separator_offset { if !run.text.is_empty() { anchor_runs.push(run.clone()); } + visible_offset = run_end; continue; - }; + } + let offset = separator_offset.saturating_sub(visible_offset); if offset > 0 { anchor_runs.push(Run { text: run.text[..offset].to_string(), @@ -1883,6 +1895,50 @@ fn extract_decimal_anchor_runs(runs: &[Run]) -> Option> { None } +fn find_decimal_separator_offset(text: &str) -> Option { + let separator = text.char_indices().rev().find(|(offset, ch)| { + matches!(ch, '.' | ',') + && has_ascii_digit_before(text, *offset) + && has_ascii_digit_after(text, *offset + ch.len_utf8()) + })?; + + if is_grouped_integer( + &text + .chars() + .filter(|ch| ch.is_ascii_digit() || matches!(ch, '.' | ',')) + .collect::(), + separator.1, + ) { + return None; + } + + Some(separator.0) +} + +fn has_ascii_digit_before(text: &str, offset: usize) -> bool { + text[..offset].chars().rev().any(|ch| ch.is_ascii_digit()) +} + +fn has_ascii_digit_after(text: &str, offset: usize) -> bool { + text[offset..].chars().any(|ch| ch.is_ascii_digit()) +} + +fn is_grouped_integer(text: &str, separator: char) -> bool { + if text + .chars() + .any(|ch| matches!(ch, '.' | ',') && ch != separator) + { + return false; + } + + let parts: Vec<&str> = text.split(separator).collect(); + parts.len() > 1 + && parts + .iter() + .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) + && parts[1..].iter().all(|part| part.len() == 3) +} + fn build_tab_advance_expr(index: usize, segment: &[Run], tab_stops: Option<&[TabStop]>) -> String { let prefix_width_var = format!("tab_prefix_width_{index}"); let segment_width_var = format!("tab_segment_width_{index}"); @@ -1925,6 +1981,50 @@ fn build_tab_advance_expr(index: usize, segment: &[Run], tab_stops: Option<&[Tab expr } +fn build_tab_fill_expr(index: usize, tab_stops: Option<&[TabStop]>) -> String { + let Some(tab_stops) = tab_stops else { + return format!("h(tab_advance_{index})"); + }; + + if tab_stops.is_empty() { + return format!("h(tab_advance_{index})"); + } + + let prefix_width_var = format!("tab_prefix_width_{index}"); + let mut expr = String::new(); + for (stop_index, stop) in tab_stops.iter().enumerate() { + let branch = tab_fill_content_expr(index, stop.leader); + + if stop_index == 0 { + let _ = write!( + expr, + "if {prefix_width_var} < {}pt {{ {branch} }}", + format_f64(stop.position) + ); + } else { + let _ = write!( + expr, + " else if {prefix_width_var} < {}pt {{ {branch} }}", + format_f64(stop.position) + ); + } + } + + let _ = write!(expr, " else {{ h(tab_advance_{index}) }}"); + expr +} + +fn tab_fill_content_expr(index: usize, leader: TabLeader) -> String { + let leader_markup = match leader { + TabLeader::None => return format!("h(tab_advance_{index})"), + TabLeader::Dot => ".", + TabLeader::Hyphen => "-", + TabLeader::Underscore => "\\_", + }; + + format!("box(width: tab_advance_{index}, repeat[{leader_markup}])") +} + fn build_default_tab_advance_expr(index: usize) -> String { format!( "if tab_default_remainder_{index} == 0 {{ {}pt }} else {{ ({} - tab_default_remainder_{index}) * 1pt }}", @@ -2587,6 +2687,87 @@ mod tests { ); } + #[test] + fn test_generate_tab_leader_uses_repeat_fill() { + use crate::ir::{TabAlignment, TabLeader, TabStop}; + + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + tab_stops: Some(vec![TabStop { + position: 144.0, + alignment: TabAlignment::Left, + leader: TabLeader::Dot, + }]), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "Heading\t12".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("box(width: tab_advance_1, repeat[.])"), + "Expected dot tab leaders to render with Typst repeat fill in: {result}" + ); + } + + #[test] + fn test_generate_decimal_tab_uses_decimal_separator_not_thousands_separator() { + use crate::ir::{TabAlignment, TabLeader, TabStop}; + + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + tab_stops: Some(vec![TabStop { + position: 180.0, + alignment: TabAlignment::Decimal, + leader: TabLeader::None, + }]), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "Total\t1,234.56".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("let tab_decimal_anchor_1 = [1,234]"), + "Expected decimal alignment to anchor after the thousands group in: {result}" + ); + } + + #[test] + fn test_generate_decimal_tab_handles_comma_decimal_locale() { + use crate::ir::{TabAlignment, TabLeader, TabStop}; + + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + tab_stops: Some(vec![TabStop { + position: 180.0, + alignment: TabAlignment::Decimal, + leader: TabLeader::None, + }]), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "Total\t1.234,56".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("let tab_decimal_anchor_1 = [1.234]"), + "Expected decimal alignment to anchor on the locale decimal separator in: {result}" + ); + } + #[test] fn test_generate_multiple_paragraphs() { let doc = make_doc(vec![make_flow_page(vec![