From 3c976d3712f9be95e18ce2f9819e18fbea94f352 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Sat, 24 Oct 2026 13:02:07 +0400 Subject: [PATCH 01/12] M12.AR3: shape and preserve Arabic PDF text before writing output --- ATTRIBUTION.md | 2 + Cargo.lock | 7 + ROADMAP.md | 4 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/print.rs | 147 +++-- crates/pdf/Cargo.toml | 9 +- crates/pdf/build.rs | 88 +++ crates/pdf/src/arabic.rs | 605 +++++++++++++++++++++ crates/pdf/src/lib.rs | 98 +++- crates/pdf/src/tests.rs | 183 ++++++- crates/xlsx/src/tests/passthrough_tests.rs | 2 +- docs/file-format-parity.md | 7 +- docs/localization-parity.md | 15 +- 13 files changed, 1109 insertions(+), 59 deletions(-) create mode 100644 crates/pdf/build.rs create mode 100644 crates/pdf/src/arabic.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a1e4d8fbcf..abeffb66eb 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -46,3 +46,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/images/pivot.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content | | `docs/images/format-chart.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content | | `docs/images/page-break.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content | + +Arabic PDF text uses Noto Sans Arabic from the optional [craft-fonts build input](https://github.com/storytold/craft-fonts), pinned by the release workflow. Author: The Noto Project Authors. Licence: SIL Open Font License 1.1. Native unbundled export uses an installed Noto Sans Arabic face. No new asset file is committed. diff --git a/Cargo.lock b/Cargo.lock index c9ef5965c2..982dff6bf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1950,6 +1950,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", "zip 3.0.0", ] @@ -2001,6 +2002,12 @@ dependencies = [ [[package]] name = "gridcraft-pdf" version = "0.12.1" +dependencies = [ + "harfrust", + "sha2 0.11.0", + "skrifa", + "unicode-bidi", +] [[package]] name = "gridcraft-ui-egui" diff --git a/ROADMAP.md b/ROADMAP.md index 48b3cf9bc7..9abbf7fea6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (Arabic PDF text is shaped and searchable; AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-26 | Arabic PDF export shapes licensed glyph outlines and preserves searchable logical text; missing or unsupported glyphs return an error before overwriting output | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | @@ -237,6 +238,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Progress log: Arabic PDF text | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e8ea91c26d..da14680650 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,7 @@ gridcraft-xlsx = { workspace = true } gridcraft-chart = { workspace = true } gridcraft-pdf = { workspace = true } image = { workspace = true } +unicode-segmentation = "=1.13.3" [dev-dependencies] gridcraft-xlsx = { workspace = true, features = ["test-support"] } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 62cc6b7538..c21d95dc1e 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -13,6 +13,7 @@ use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::*; use gridcraft_pdf::{Font, Page, PdfDoc, Rgb}; use serde_json::{Value as Json, json}; +use unicode_segmentation::UnicodeSegmentation; use super::*; @@ -802,27 +803,32 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { out.push(std::mem::take(&mut line)); line = word.to_string(); } - // Break a single over-long word by characters. The cut that fits is found by binary - // search (each try measures one candidate prefix), so an over-long word costs - // O(n log n) measurements instead of re-measuring the whole cut per character for - // every emitted line (`a` × 100k pasted text would be cubic worst case). - while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let chars: Vec = line.chars().collect(); - let (mut lo, mut hi) = (1usize, chars.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let cut: String = chars[..mid].iter().collect(); - if gridcraft_pdf::text_width(font, size, &cut) <= width { - lo = mid; + // Keep marks with their base letter. Binary search avoids shaping every prefix of + // a long Arabic word; each emitted prefix is measured in its final joined form. + while gridcraft_pdf::text_width(font, size, &line) > width { + let boundaries: Vec = line.grapheme_indices(true).map(|(i, _)| i).chain(std::iter::once(line.len())).collect(); + let count = boundaries.len().saturating_sub(1); + if count <= 1 { + break; + } + let (mut low, mut high, mut split) = (1, count - 1, 1); + while low <= high { + let mid = low + (high - low) / 2; + let end = boundaries.get(mid).copied().unwrap_or(line.len()); + let prefix = line.get(..end).unwrap_or_default(); + if gridcraft_pdf::text_width(font, size, prefix) <= width { + split = mid; + low = mid + 1; } else { - hi = mid - 1; + high = mid - 1; } } - if lo == chars.len() { - break; + let end = boundaries.get(split).copied().unwrap_or(line.len()); + out.push(line.get(..end).unwrap_or_default().to_string()); + line = line.get(end..).unwrap_or_default().to_string(); + if out.len() > 2000 { + return out; } - out.push(chars[..lo].iter().collect()); - line = chars[lo..].iter().collect(); } if out.len() > 2000 { return out; @@ -1204,12 +1210,25 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], (false, _) => vec![text], }; - let line_h = size * 1.2; + let extents = lines + .iter() + .filter_map(|line| pg.text_vertical_extents(font, size, line)) + .reduce(|a, b| (a.0.max(b.0), a.1.max(b.1))) + .map(|(ascent, descent)| (ascent.max(size * 0.8), descent.max(size * 0.22))); + let line_h = extents.map_or(size * 1.2, |(ascent, descent)| (ascent + descent + size * 0.2).max(size * 1.2)); let n = lines.len().max(1) as f32; - let first_baseline = match st.align.v { - VAlign::Top => y + pad + size * 0.8, - VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33, - VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, + let first_baseline = if let Some((ascent, descent)) = extents { + match st.align.v { + VAlign::Top => y + pad + ascent, + VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - (n - 1.0) * line_h - ascent - descent) / 2.0 + ascent, + VAlign::Bottom => y + h - pad - descent - (n - 1.0) * line_h, + } + } else { + match st.align.v { + VAlign::Top => y + pad + size * 0.8, + VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33, + VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, + } }; // Overflow into empty neighbours (single-line text, not merged). // Center Across Selection spans the empty Center Across cells to its right; the text stays within that run. @@ -1259,9 +1278,9 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 for (i, t) in lines.iter().enumerate() { let i = i as f32; let (ox, oy) = if deg > 0.0 { - (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) + (x + pad + extents.map_or(size * 0.8, |e| e.0) * deg.to_radians().sin() + i * line_h, y + h - pad) } else { - (x + pad + (last - i) * line_h, y + pad + size * 0.8) + (x + pad + (last - i) * line_h, y + pad + extents.map_or(size * 0.8, |e| e.0)) }; pg.text_rotated(ox, oy, size, font, color, t, angle); } @@ -1270,12 +1289,12 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 } if rot == 255 { // Vertical stacked text. - let chars: Vec = lines.concat().chars().take(500).collect(); - let mut yy = y + pad + size * 0.8; + let stacked = lines.concat(); + let chars: Vec<&str> = stacked.graphemes(true).take(500).collect(); + let mut yy = y + pad + extents.map_or(size * 0.8, |e| e.0); for ch in chars { - let s = ch.to_string(); - let cw = gridcraft_pdf::text_width(font, size, &s); - pg.text(x + (w - cw) / 2.0, yy, size, font, color, &s); + let cw = gridcraft_pdf::text_width(font, size, ch); + pg.text(x + (w - cw) / 2.0, yy, size, font, color, ch); yy += line_h; if yy > y + h + line_h { break; @@ -1563,7 +1582,7 @@ fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 { } /// Renders laid-out sheets to a PDF. -pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec, usize) { +pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> Result<(Vec, usize)> { let mut doc = PdfDoc::new(); doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() }; doc.info.author = wb.props.author.clone(); @@ -1639,7 +1658,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str } } let n = doc.page_count(); - (doc.finish(), n) + Ok((doc.finish().map_err(|error| EngineError::Other(error.to_string()))?, n)) } // ---------------------------------------------------------------- commands @@ -1649,7 +1668,7 @@ fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec, usize)> { let d = s.doc()?; let lays = layouts(&d.wb, &opts); let path = d.path.clone().unwrap_or_default(); - Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path)) + render_pdf(&d.wb, &lays, &d.display_title(), &path) } pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result { @@ -1906,6 +1925,70 @@ mod tests { assert!(!cropped.contains("/Dest [")); } + #[test] + fn unrenderable_arabic_does_not_overwrite_the_export_destination() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 😀"})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-arabic-failed-export-{}.pdf", std::process::id())); + std::fs::write(&path, b"previous document").unwrap(); + let result = s.execute("file.exportPdf", json!({"path": path.to_str().unwrap()})); + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"previous document"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Text("مرحبا 😀".into())); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn wrapping_keeps_combining_marks_with_their_letters() { + let word = "a\u{301}a\u{301}a\u{301}a\u{301}"; + let lines = wrap_lines(word, Font::Helvetica, 10.0, 7.0); + assert_eq!(lines.concat(), word); + assert!(lines.iter().all(|line| line == "a\u{301}")); + } + + #[test] + fn arabic_cells_headers_and_charts_export_searchable_shaped_text() { + if gridcraft_pdf::text_width(Font::Helvetica, 10.0, "مرحبا") == 0.0 && std::env::var_os("CRAFT_FONTS_REQUIRED").is_none() { + return; // Optional font input; required-font CI exercises this regression. + } + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["مَرْحَبًا 123 (ABC)", "القيمة"], ["تفاح", 3], ["كمثرى", 5], ["العَرَبِيَّة العَرَبِيَّة العَرَبِيَّة العَرَبِيَّة العَرَبِيَّة", null], ["تقرير ١٢٣ (ABC)", null], ["مرحبا", null]]})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 32})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:6", "height": 50})).unwrap(); + s.execute("home.fontSize", json!({"range": "A1:B6", "size": 20})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "4:4", "height": 90})).unwrap(); + s.execute("home.bold", json!({"range": "A2"})).unwrap(); + s.execute("home.italic", json!({"range": "A3"})).unwrap(); + s.execute("home.wrapText", json!({"range": "A4"})).unwrap(); + s.execute("home.orientation", json!({"range": "A6", "angle": 30})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"header": "&Cتقرير المبيعات 2026", "gridlines": true})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:B3", "type": "column"})).unwrap(); + // Keep the chart below the table in this synthetic workbook. + { + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + for chart in &mut wb.sheet_mut(0).unwrap().charts { + chart.anchor.cell = CellRef::new(8, 0); + chart.anchor.width = 500.0; + chart.anchor.height = 280.0; + } + } + let (pdf, pages) = pdf_of(&mut s, json!({"fitToPage": true})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "/Subtype /Type3")); + for text in ["مَرْحَبًا 123 (ABC)", "تقرير المبيعات 2026", "القيمة", "تفاح", "كمثرى"] { + let utf16 = text.encode_utf16().map(|unit| format!("{unit:04X}")).collect::(); + assert!(contains(&pdf, &format!("/ActualText ")), "missing text {text}"); + } + let lines = wrap_lines("العَرَبِيَّةالعَرَبِيَّة", Font::Helvetica, 20.0, 40.0); + assert_eq!(lines.concat(), "العَرَبِيَّةالعَرَبِيَّة"); + assert!(lines.iter().all(|line| !line.starts_with(['َ', 'ِ', 'ُ', 'ّ', 'ْ']))); + assert!(lines.iter().all(|line| gridcraft_pdf::text_width(Font::Helvetica, 20.0, line) <= 40.0)); + let path = std::env::temp_dir().join(format!("gridcraft-arabic-export-{}.pdf", std::process::id())); + std::fs::write(&path, &pdf).unwrap(); + eprintln!("Arabic PDF artifact: {}", path.display()); + } + #[test] fn header_sections_split_join_and_field_names() { let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 6cb0d2e3a8..e7df2fd749 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -3,9 +3,16 @@ name = "gridcraft-pdf" version.workspace = true edition.workspace = true license.workspace = true -description = "GridCraft's tiny self-contained PDF 1.7 writer (standard 14 fonts, vector drawing, RGBA images)" +description = "GridCraft PDF 1.7 writer with shaped Arabic text, vector drawing and RGBA images" [lints] workspace = true [dependencies] +harfrust = "=0.12.0" +skrifa = { workspace = true } +unicode-bidi = "=0.3.18" + +[build-dependencies] +sha2 = { workspace = true } +skrifa = { workspace = true } diff --git a/crates/pdf/build.rs b/crates/pdf/build.rs new file mode 100644 index 0000000000..a5f132434e --- /dev/null +++ b/crates/pdf/build.rs @@ -0,0 +1,88 @@ +use std::fmt::Write as _; + +fn main() -> Result<(), String> { + arabic_fonts() +} + +/// Embed only Arabic fallback faces from the optional, separately licensed font bundle. +fn arabic_fonts() -> Result<(), String> { + use std::path::PathBuf; + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let entries = match std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + Some(dir) => arabic_font_entries(&dir), + None if required => Err("CRAFT_FONTS_REQUIRED needs CRAFT_FONTS_DIR".into()), + None => Ok(String::new()), + }; + let entries = match entries { + Ok(entries) => entries, + Err(e) if required => return Err(e), + Err(e) => { + println!("cargo::warning=building without bundled Arabic fonts: {e}"); + String::new() + } + }; + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("arabic_fonts.rs"); + std::fs::write(&out, format!("pub(crate) static ARABIC_FONTS: &[(&str, &[u8])] = &[\n{entries}];\n")) + .map_err(|e| format!("writing {}: {e}", out.display())) +} + +fn arabic_font_entries(dir: &std::path::Path) -> Result { + use sha2::{Digest, Sha256}; + use skrifa::MetadataProvider; + let root = dir.canonicalize().map_err(|e| format!("craft-fonts directory: {e}"))?; + let manifest = root.join("fonts/manifest.txt"); + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = String::from_utf8(read_font_input(&manifest, 1 << 20)?).map_err(|e| e.to_string())?; + let mut out = String::new(); + let mut has_regular = false; + for (line_number, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields: Vec<_> = line.split(" | ").map(str::trim).collect(); + let [family, style, file, scripts, _licence, licence_file, checksum, _source] = fields.as_slice() else { + return Err(format!("malformed craft-fonts manifest line {}", line_number + 1)); + }; + if !scripts.split(',').any(|script| script.trim() == "Arab") { + continue; + } + let path = root.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; + let licence = root.join(licence_file).canonicalize().map_err(|e| format!("{licence_file}: {e}"))?; + if !path.starts_with(&root) || !licence.starts_with(&root) { + return Err("craft-fonts paths must stay inside the checkout".into()); + } + println!("cargo::rerun-if-changed={}", path.display()); + println!("cargo::rerun-if-changed={}", licence.display()); + let bytes = read_font_input(&path, 64 << 20)?; + if Sha256::digest(&bytes).iter().map(|byte| format!("{byte:02x}")).collect::() != *checksum { + return Err(format!("checksum mismatch for {file}")); + } + let font = skrifa::FontRef::from_index(&bytes, 0).map_err(|e| format!("invalid Arabic font {file}: {e}"))?; + if !"العربية١٢٣".chars().all(|ch| font.charmap().map(ch).is_some()) { + return Err(format!("{file} lacks Arabic glyphs")); + } + let _ = writeln!(out, " ({:?}, include_bytes!({:?})),", format!("craft:{family}:{style}"), path); + has_regular |= *style != "Bold"; + } + if out.is_empty() { + return Err("craft-fonts manifest contains no Arabic face; update the checkout".into()); + } + if !has_regular { + return Err("craft-fonts manifest has only bold Arabic faces; regular text needs a regular or variable face".into()); + } + Ok(out) +} + +fn read_font_input(path: &std::path::Path, limit: u64) -> Result, String> { + use std::io::Read as _; + let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if bytes.len() as u64 > limit { + return Err(format!("{} exceeds the font input size limit", path.display())); + } + Ok(bytes) +} diff --git a/crates/pdf/src/arabic.rs b/crates/pdf/src/arabic.rs new file mode 100644 index 0000000000..665fb61edc --- /dev/null +++ b/crates/pdf/src/arabic.rs @@ -0,0 +1,605 @@ +//! Arabic shaping and subset Type3 fonts. Outlines use the same variation coordinates as the +//! shaper; ActualText preserves the logical source independently of visual glyph order. +use std::{borrow::Cow, collections::BTreeMap, fmt::Write as _, sync::OnceLock}; + +use harfrust::{Direction, ShaperData, ShaperInstance, UnicodeBuffer}; +use skrifa::{ + FontRef, MetadataProvider, + instance::Size, + outline::{DrawSettings, OutlinePen}, +}; +use unicode_bidi::BidiInfo; + +use crate::{CID_FONT, Error, Font, n, pdf_string, pdf_utf16_string, win_ansi}; + +include!(concat!(env!("OUT_DIR"), "/arabic_fonts.rs")); + +const MAX_TEXT_BYTES: usize = 128 * 1024; +const MAX_OUTLINE_BYTES: usize = 256 * 1024; +const MAX_SUBSET_BYTES: usize = 64 * 1024 * 1024; +const MAX_SUBSET_GLYPHS: usize = 16_384; +const SYNTHETIC_BOLD_STROKE: f32 = 30.0; + +pub(crate) fn needed(text: &str) -> bool { + text.chars().any(|ch| matches!(ch as u32, 0x0600..=0x06ff | 0x0750..=0x077f | 0x0870..=0x089f | 0x08a0..=0x08ff | 0xfb50..=0xfdff | 0xfe70..=0xfefc | 0x10e60..=0x10e7f | 0x1ee00..=0x1eeff)) +} + +fn bold(font: Font) -> bool { + matches!( + font, + Font::HelveticaBold | Font::HelveticaBoldOblique | Font::TimesBold | Font::TimesBoldItalic | Font::CourierBold | Font::CourierBoldOblique + ) +} + +fn italic(font: Font) -> bool { + matches!( + font, + Font::HelveticaOblique + | Font::HelveticaBoldOblique + | Font::TimesItalic + | Font::TimesBoldItalic + | Font::CourierOblique + | Font::CourierBoldOblique + ) +} + +struct Face { + bytes: Cow<'static, [u8]>, + data: ShaperData, + instance: ShaperInstance, + synthetic_bold: bool, +} + +impl Face { + fn new(bytes: Cow<'static, [u8]>, bold: bool) -> Result { + let font = FontRef::from_index(&bytes, 0).map_err(|e| Error(format!("Invalid Arabic PDF font: {e}")))?; + if !"العربية١٢٣".chars().all(|ch| font.charmap().map(ch).is_some()) { + return Err(Error("The PDF font lacks Arabic glyphs".into())); + } + let data = ShaperData::new(&font); + let instance = ShaperInstance::from_variations(&font, [("wght", if bold { 700.0 } else { 400.0 })]); + Ok(Self { bytes, data, instance, synthetic_bold: false }) + } + + fn font(&self) -> Result, Error> { + FontRef::from_index(&self.bytes, 0).map_err(|e| Error(format!("Invalid Arabic PDF font: {e}"))) + } +} + +#[cfg(test)] +pub(crate) fn bold_face_is_synthetic() -> bool { + face(true).is_ok_and(|face| face.synthetic_bold) +} + +fn face(is_bold: bool) -> Result<&'static Face, Error> { + static REGULAR: OnceLock = OnceLock::new(); + static BOLD: OnceLock = OnceLock::new(); + let cache = if is_bold { &BOLD } else { ®ULAR }; + if let Some(face) = cache.get() { + return Ok(face); + } + let loaded = load_face(is_bold)?; + Ok(cache.get_or_init(|| loaded)) +} + +fn bundled_match(fonts: &[(&str, &'static [u8])], is_bold: bool) -> Option<&'static [u8]> { + fonts + .iter() + .find(|(name, bytes)| if is_bold { name.ends_with(":Bold") || has_weight_axis(bytes) } else { !name.ends_with(":Bold") }) + .map(|&(_, bytes)| bytes) +} + +fn load_face(is_bold: bool) -> Result { + if let Some(bytes) = bundled_match(ARABIC_FONTS, is_bold) { + return Face::new(Cow::Borrowed(bytes), is_bold); + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::{io::Read as _, path::PathBuf}; + // Only the redistributable Noto family is used. Never embed proprietary system fonts. + let mut dirs: Vec = [ + "/usr/share/fonts/noto", + "/usr/share/fonts/google-noto-vf", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/TTF", + "/usr/local/share/fonts", + "/usr/local/share/fonts/noto", + "/usr/local/share/fonts/TTF", + "/Library/Fonts", + ] + .into_iter() + .map(Into::into) + .collect(); + if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { + let home = PathBuf::from(home); + dirs.extend([home.join(".local/share/fonts"), home.join(".fonts"), home.join("Library/Fonts")]); + } + if let Some(root) = std::env::var_os("WINDIR") { + dirs.push(PathBuf::from(root).join("Fonts")); + } + if let Some(root) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(root).join("Microsoft/Windows/Fonts")); + } + let filename = if is_bold { "NotoSansArabic-Bold.ttf" } else { "NotoSansArabic-Regular.ttf" }; + for dir in dirs { + for name in ["NotoSansArabic.ttf", "NotoSansArabic[wght].ttf", filename] { + let path = dir.join(name); + let file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(Error(format!("Cannot read Arabic PDF font {}: {e}", path.display()))), + }; + let mut bytes = Vec::new(); + file.take((64 << 20) + 1).read_to_end(&mut bytes).map_err(|e| Error(format!("Cannot read Arabic PDF font: {e}")))?; + if bytes.len() > 64 << 20 { + return Err(Error("Arabic PDF font exceeds 64 MiB".into())); + } + if is_bold && name != filename && !has_weight_axis(&bytes) { + continue; + } + return Face::new(Cow::Owned(bytes), is_bold); + } + } + } + if let Some((name, bytes)) = bundled_fallback(ARABIC_FONTS, is_bold) { + let mut face = Face::new(Cow::Borrowed(bytes), is_bold)?; + face.synthetic_bold = needs_synthetic_bold(name, bytes, is_bold); + return Ok(face); + } + Err(Error("Arabic PDF export needs Noto Sans Arabic. Install its regular and bold fonts, or build with CRAFT_FONTS_DIR.".into())) +} + +/// The bundled face to use when no face matches the requested weight: regular text never borrows a bold face. +fn bundled_fallback<'a>(fonts: &[(&'a str, &'static [u8])], is_bold: bool) -> Option<(&'a str, &'static [u8])> { + fonts.iter().find(|(name, _)| is_bold || !name.ends_with(":Bold")).copied() +} + +/// A bold request served by a static regular face is emboldened by stroking its outlines. +fn needs_synthetic_bold(name: &str, bytes: &[u8], is_bold: bool) -> bool { + is_bold && !name.ends_with(":Bold") && !has_weight_axis(bytes) +} + +fn has_weight_axis(bytes: &[u8]) -> bool { + FontRef::from_index(bytes, 0).is_ok_and(|font| font.axes().iter().any(|axis| axis.tag() == skrifa::raw::types::Tag::new(b"wght"))) +} + +#[derive(Debug)] +pub(crate) enum Item { + Glyph { id: u32, x: f32, y: f32, source: String }, + Latin { text: String, x: f32 }, + Cid { text: String, x: f32 }, +} + +pub(crate) struct Layout { + pub items: Vec, + pub width: f32, +} + +/// Positions are in 1/1000 em, matching the Type3 font matrix. +pub(crate) fn layout(font: Font, text: &str) -> Result { + if text.len() > MAX_TEXT_BYTES { + return Err(Error("Arabic PDF text exceeds the 128 KiB per-line limit".into())); + } + let face = face(bold(font))?; + let raw = face.font()?; + let shaper = face.data.shaper(&raw).instance(Some(&face.instance)).build(); + let units = shaper.units_per_em(); + if units <= 0 { + return Err(Error("Arabic PDF font has invalid units per em".into())); + } + let scale = 1000.0 / units as f32; + let single_line: String = text.chars().map(|ch| if is_paragraph_break(ch) { ' ' } else { ch }).collect(); + let text = single_line.as_str(); + let bidi = BidiInfo::new(text, None); + let mut layout = Layout { items: Vec::new(), width: 0.0 }; + for para in &bidi.paragraphs { + let (levels, runs) = bidi.visual_runs(para, para.range.clone()); + for run in runs { + let rtl = levels.get(run.start).is_some_and(|level| level.is_rtl()); + let run_text = text.get(run.clone()).ok_or_else(|| Error("Invalid PDF text boundary".into()))?; + // Older Noto Arabic fonts omit Latin. Split only on coverage changes, retaining + // Arabic joining context and reversing the span order inside RTL runs. + let mut spans = Vec::new(); + let mut start = 0; + let mut embedded = None; + for (i, ch) in run_text.char_indices() { + let supported = embeddable(raw.charmap().map(ch).is_some(), ch); + if embedded.is_some_and(|v| v != supported) { + spans.push((start..i, embedded.unwrap_or(false))); + start = i; + } + embedded = Some(supported); + } + spans.push((start..run_text.len(), embedded.unwrap_or(false))); + if rtl { + spans.reverse(); + } + for (span, embedded) in spans { + let part = run_text.get(span.clone()).ok_or_else(|| Error("Invalid PDF text boundary".into()))?; + if !embedded { + if part.chars().any(|ch| win_ansi(ch) == b'?' && ch != '?' && !is_line_break(ch) && !cid_covers(ch)) { + return Err(Error("The Arabic PDF font does not cover all characters in this text".into())); + } + let part = part.chars().map(|ch| if is_line_break(ch) { ' ' } else { ch }); + let part: Vec = if rtl { part.rev().map(mirror_ansi).collect() } else { part.collect() }; + for run in part.chunk_by(|a, b| cid_covers(*a) == cid_covers(*b)) { + let (text, x) = (run.iter().collect(), layout.width); + layout.width += run.iter().map(|&ch| f32::from(font.char_width(ch))).sum::(); + layout.items.push(if run.first().is_some_and(|&ch| cid_covers(ch)) { + Item::Cid { text, x } + } else { + Item::Latin { text, x } + }); + } + continue; + } + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(part); + buffer.set_pre_context(run_text.get(..span.start).unwrap_or_default()); + buffer.set_post_context(run_text.get(span.end..).unwrap_or_default()); + buffer.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + buffer.set_flags(harfrust::BufferFlags::REMOVE_DEFAULT_IGNORABLES); + if needed(part) { + buffer.set_script(harfrust::script::ARABIC); + } + buffer.guess_segment_properties(); + let shaped = shaper.shape(buffer, harfrust::ShapeOptions::new()); + let mut clusters: Vec<_> = shaped.glyph_infos().iter().map(|g| g.cluster as usize).collect(); + clusters.push(part.len()); + clusters.sort_unstable(); + clusters.dedup(); + let mut previous_cluster = None; + for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + if info.glyph_id == 0 { + return Err(Error("The Arabic PDF font produced a missing glyph".into())); + } + let cluster = info.cluster as usize; + let source = if previous_cluster == Some(cluster) { + String::new() + } else { + let index = clusters.binary_search(&cluster).map_err(|_| Error("Invalid PDF glyph cluster".into()))?; + let end = clusters.get(index + 1).copied().ok_or_else(|| Error("Invalid PDF glyph cluster".into()))?; + part.get(cluster..end).ok_or_else(|| Error("Invalid PDF glyph cluster".into()))?.to_string() + }; + previous_cluster = Some(cluster); + layout.items.push(Item::Glyph { + id: info.glyph_id, + x: layout.width + pos.x_offset as f32 * scale, + y: pos.y_offset as f32 * scale, + source, + }); + layout.width += pos.x_advance as f32 * scale; + } + } + } + } + Ok(layout) +} + +pub(crate) fn vertical_extents(font: Font, text: &str) -> Result<(f32, f32), Error> { + let layout = layout(font, text)?; + let face = face(bold(font))?; + let raw = face.font()?; + let metrics = raw.glyph_metrics(Size::new(1000.0), skrifa::instance::LocationRef::new(face.instance.coords())); + let (mut ascent, mut descent) = (0.0_f32, 0.0_f32); + for item in layout.items { + match item { + Item::Glyph { id, y, .. } => { + let (bottom, top) = if let Some(bounds) = metrics.bounds(id.into()) { + (bounds.y_min, bounds.y_max) + } else { + // Variable-font spaces have a valid empty outline but no ink bounding box. + let glyph = raw.outline_glyphs().get(id.into()).ok_or_else(|| Error("Missing Arabic PDF glyph outline".into()))?; + let mut pen = Pen::default(); + glyph + .draw(DrawSettings::unhinted(Size::new(1000.0), skrifa::instance::LocationRef::new(face.instance.coords())), &mut pen) + .map_err(|e| Error(format!("Cannot measure Arabic PDF glyph: {e}")))?; + if pen.full { + return Err(Error("Arabic PDF glyph exceeds the outline size limit".into())); + } + (pen.bounds[1], pen.bounds[3]) + }; + ascent = ascent.max(y + top); + descent = descent.max(-y - bottom); + } + Item::Latin { .. } | Item::Cid { .. } => { + ascent = ascent.max(800.0); + descent = descent.max(220.0); + } + } + } + Ok((ascent, descent)) +} + +/// Characters the Adobe-GB1 CID font sets when the Arabic face lacks them: CJK punctuation, +/// kana, bopomofo, ideographs and full-width forms. Anything else uncovered fails the export. +fn cid_covers(ch: char) -> bool { + matches!(ch, '\u{3000}'..='\u{312F}' | '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{FF00}'..='\u{FFEF}') +} + +// The mirrored characters representable by WinAnsi. HarfRust handles embedded glyphs. +fn mirror_ansi(ch: char) -> char { + match ch { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + '‹' => '›', + '›' => '‹', + other => other, + } +} + +/// Whether a character is drawn from the embedded Arabic face. Line breaks never are, so the +/// fallback path draws them as spaces even when the face maps them. +fn embeddable(mapped: bool, ch: char) -> bool { + (mapped || is_control(ch)) && !is_line_break(ch) +} + +fn is_line_break(ch: char) -> bool { + matches!(ch, '\n' | '\r' | '\u{2028}' | '\u{2029}') +} + +/// Bidi paragraph separators (class B). A drawn line is one paragraph: left in, they would split it +/// into paragraphs laid out left to right, swapping the parts of a right-to-left line. +fn is_paragraph_break(ch: char) -> bool { + matches!(ch, '\n' | '\r' | '\u{1C}'..='\u{1E}' | '\u{85}' | '\u{2029}') +} + +fn is_control(ch: char) -> bool { + matches!(ch, '\u{061c}' | '\u{200c}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') +} + +#[derive(Default)] +pub(crate) struct Subset { + keys: BTreeMap<(u32, bool, bool, String), usize>, + glyphs: Vec, + bytes: usize, + /// Some text on the page set a run in the CJK CID font, so the page must declare it. + pub(crate) uses_cid: bool, +} + +struct Glyph { + path: String, + width: f32, + source: String, + bounds: [f32; 4], + emboldened: bool, +} + +/// The Type3 glyph procedure: a filled outline, also stroked in the text colour when emboldened. +fn glyph_procedure(glyph: &Glyph) -> String { + if glyph.emboldened { + format!("{} 0 d0\n{} w 1 j\n{}B\n", n(glyph.width), n(SYNTHETIC_BOLD_STROKE), glyph.path) + } else { + format!("{} 0 d0\n{}f\n", n(glyph.width), glyph.path) + } +} + +impl Subset { + fn insert(&mut self, font: Font, id: u32, source: String) -> Result { + let key = (id, bold(font), italic(font), source.clone()); + if let Some(index) = self.keys.get(&key) { + return Ok(*index); + } + if self.glyphs.len() >= MAX_SUBSET_GLYPHS { + return Err(Error("PDF page has too many distinct Arabic glyphs".into())); + } + let face = face(bold(font))?; + let raw = face.font()?; + let location = skrifa::instance::LocationRef::new(face.instance.coords()); + let glyph = raw.outline_glyphs().get(id.into()).ok_or_else(|| Error("Missing Arabic PDF glyph outline".into()))?; + let width = + raw.glyph_metrics(Size::new(1000.0), location).advance_width(id.into()).ok_or_else(|| Error("Missing Arabic PDF glyph width".into()))?; + let mut pen = Pen { shear: if italic(font) { 0.212_556_56 } else { 0.0 }, ..Default::default() }; + glyph.draw(DrawSettings::unhinted(Size::new(1000.0), location), &mut pen).map_err(|e| Error(format!("Cannot draw Arabic PDF glyph: {e}")))?; + if pen.full || self.bytes.saturating_add(pen.path.len()).saturating_add(source.len().saturating_mul(2)) > MAX_SUBSET_BYTES { + return Err(Error("Arabic PDF outlines exceed the page size limit".into())); + } + let index = self.glyphs.len(); + self.bytes += pen.path.len() + source.len() * 2; + let emboldened = face.synthetic_bold; + let grow = if emboldened { SYNTHETIC_BOLD_STROKE / 2.0 } else { 0.0 }; + let [x0, y0, x1, y1] = pen.bounds; + self.glyphs.push(Glyph { path: pen.path, width, source, bounds: [x0 - grow, y0 - grow, x1 + grow, y1 + grow], emboldened }); + self.keys.insert(key, index); + Ok(index) + } + + pub(crate) fn text(&mut self, font: Font, text: &str, size: f32) -> Result { + let layout = layout(font, text)?; + let mut out = format!("/Span << /ActualText {} >> BDC\nBT\n", unicode_string(text, true)); + for item in layout.items { + match item { + Item::Glyph { id, x, y, source } => { + let index = self.insert(font, id, source)?; + let _ = writeln!( + out, + "/A{} {} Tf 1 0 0 1 {} {} Tm <{:02X}> Tj", + index / 255 + 1, + n(size), + n(x * size / 1000.0), + n(y * size / 1000.0), + index % 255 + 1 + ); + } + Item::Latin { text, x } => { + let _ = writeln!(out, "/F{} {} Tf 1 0 0 1 {} 0 Tm {} Tj", font.index() + 1, n(size), n(x * size / 1000.0), pdf_string(&text)); + } + Item::Cid { text, x } => { + self.uses_cid = true; + let _ = writeln!(out, "/F{} {} Tf 1 0 0 1 {} 0 Tm {} Tj", CID_FONT + 1, n(size), n(x * size / 1000.0), pdf_utf16_string(&text)); + } + } + } + out.push_str("ET\nEMC"); + Ok(out) + } + + pub(crate) fn objects(&self, next: &mut u32, objects: &mut Vec<(u32, Vec)>) -> String { + let mut resources = String::new(); + for (batch, glyphs) in self.glyphs.chunks(255).enumerate() { + let id = *next; + let cmap_id = id + 1; + *next += 2; + let mut procs = String::new(); + let mut names = String::new(); + let mut widths = String::new(); + let mut mappings = Vec::new(); + let mut bounds = [0.0_f32; 4]; + for (i, glyph) in glyphs.iter().enumerate() { + let code = i + 1; + let _ = write!(procs, "/g{code} {} 0 R ", *next); + let _ = write!(names, "/g{code} "); + let _ = write!(widths, "{} ", n(glyph.width)); + let body = glyph_procedure(glyph); + objects.push((*next, stream(&body))); + *next += 1; + bounds[0] = bounds[0].min(glyph.bounds[0]); + bounds[1] = bounds[1].min(glyph.bounds[1]); + bounds[2] = bounds[2].max(glyph.bounds[2]); + bounds[3] = bounds[3].max(glyph.bounds[3]); + if !glyph.source.is_empty() { + mappings.push(format!("<{code:02X}> {}\n", unicode_string(&glyph.source, false))); + } + } + let mut cmap = "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /GridCraftArabicUnicode def\n/CMapType 2 def\n1 begincodespacerange\n<00> \nendcodespacerange\n".to_string(); + for group in mappings.chunks(100) { + let _ = writeln!(cmap, "{} beginbfchar", group.len()); + for mapping in group { + cmap.push_str(mapping); + } + cmap.push_str("endbfchar\n"); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + objects.push((cmap_id, stream(&cmap))); + objects.push((id, format!("<< /Type /Font /Subtype /Type3 /Name /GridCraftArabic /FontBBox [{} {} {} {}] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << {procs}>> /Encoding << /Type /Encoding /Differences [1 {names}] >> /FirstChar 1 /LastChar {} /Widths [{widths}] /Resources << >> /ToUnicode {cmap_id} 0 R >>", n(bounds[0]), n(bounds[1]), n(bounds[2]), n(bounds[3]), glyphs.len()).into_bytes())); + let _ = write!(resources, "/A{} {id} 0 R ", batch + 1); + } + resources + } +} + +fn stream(body: &str) -> Vec { + format!("<< /Length {} >>\nstream\n{body}endstream", body.len()).into_bytes() +} + +pub(crate) fn unicode_string(text: &str, bom: bool) -> String { + let mut out = if bom { "'); + out +} + +#[derive(Default)] +struct Pen { + path: String, + point: [f32; 2], + bounds: [f32; 4], + shear: f32, + full: bool, +} + +impl Pen { + fn point(&mut self, x: f32, y: f32) -> [f32; 2] { + let x = x + self.shear * y; + self.bounds[0] = self.bounds[0].min(x); + self.bounds[1] = self.bounds[1].min(y); + self.bounds[2] = self.bounds[2].max(x); + self.bounds[3] = self.bounds[3].max(y); + [x, y] + } + fn op(&mut self, value: &str) { + if self.path.len().saturating_add(value.len()) > MAX_OUTLINE_BYTES { + self.full = true; + } + if !self.full { + self.path.push_str(value); + } + } +} + +impl OutlinePen for Pen { + fn move_to(&mut self, x: f32, y: f32) { + let [px, py] = self.point(x, y); + self.op(&format!("{} {} m\n", n(px), n(py))); + self.point = [x, y]; + } + fn line_to(&mut self, x: f32, y: f32) { + let [px, py] = self.point(x, y); + self.op(&format!("{} {} l\n", n(px), n(py))); + self.point = [x, y]; + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + self.curve_to( + self.point[0] + (cx - self.point[0]) * 2.0 / 3.0, + self.point[1] + (cy - self.point[1]) * 2.0 / 3.0, + x + (cx - x) * 2.0 / 3.0, + y + (cy - y) * 2.0 / 3.0, + x, + y, + ); + } + fn curve_to(&mut self, cx: f32, cy: f32, dx: f32, dy: f32, x: f32, y: f32) { + let [cx, cy] = self.point(cx, cy); + let [dx, dy] = self.point(dx, dy); + let [px, py] = self.point(x, y); + self.op(&format!("{} {} {} {} {} {} c\n", n(cx), n(cy), n(dx), n(dy), n(px), n(py))); + self.point = [x, y]; + } + fn close(&mut self) { + self.op("h\n"); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn fallback_mirrors_only_paired_ansi_punctuation() { + let visual: String = "(مرحبا)".chars().rev().map(super::mirror_ansi).collect(); + assert_eq!(visual, "(ابحرم)"); + assert_eq!("[(<«‹abc›»>)]".chars().rev().map(super::mirror_ansi).collect::(), "[(<«‹cba›»>)]"); + } + + #[test] + fn a_bold_request_served_by_a_static_regular_face_is_emboldened() { + assert!(super::needs_synthetic_bold("craft:Noto Sans Arabic:Regular", b"static", true)); + assert!(!super::needs_synthetic_bold("craft:Noto Sans Arabic:Regular", b"static", false)); + assert!(!super::needs_synthetic_bold("craft:Noto Sans Arabic:Bold", b"static", true)); + let glyph = + |emboldened| super::Glyph { path: "0 0 m 10 0 l 10 10 l h\n".into(), width: 500.0, source: String::new(), bounds: [0.0; 4], emboldened }; + assert_eq!(super::glyph_procedure(&glyph(false)), "500 0 d0\n0 0 m 10 0 l 10 10 l h\nf\n"); + assert_eq!(super::glyph_procedure(&glyph(true)), "500 0 d0\n30 w 1 j\n0 0 m 10 0 l 10 10 l h\nB\n"); + } + + #[test] + fn line_breaks_never_use_the_embedded_face() { + for ch in ['\n', '\r', '\u{2028}', '\u{2029}'] { + assert!(!super::embeddable(true, ch), "{ch:?}"); + } + assert!(super::embeddable(true, 'م')); + assert!(super::embeddable(false, '\u{200f}')); + assert!(!super::embeddable(false, 'A')); + } + + #[test] + fn bundled_faces_match_the_requested_weight() { + const REGULAR: &[u8] = b"regular"; + const BOLD: &[u8] = b"bold"; + let bold_first = [("Noto Sans Arabic:Bold", BOLD), ("Noto Sans Arabic", REGULAR)]; + assert_eq!(super::bundled_match(&bold_first, false), Some(REGULAR)); + assert_eq!(super::bundled_match(&bold_first, true), Some(BOLD)); + assert_eq!(super::bundled_match(&[("Noto Sans Arabic", REGULAR)], true), None); + assert_eq!(super::bundled_match(&[], false), None); + assert_eq!(super::bundled_fallback(&[("Noto Sans Arabic:Bold", BOLD)], false), None); + assert_eq!(super::bundled_fallback(&[("Noto Sans Arabic:Regular", REGULAR)], true), Some(("Noto Sans Arabic:Regular", REGULAR))); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e00b4fcc3c..3decaf2540 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -4,17 +4,29 @@ //! ([`text_width`]) so callers can measure, align and wrap text. Characters outside //! WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID //! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`. +//! - Arabic is shaped and set in embedded Type3 subsets, with exact shaped advances. //! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping. //! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded). //! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline. //! -//! Streams are written uncompressed (images use the trivial RunLengthDecode filter) so the crate -//! needs no dependencies. +//! Streams are written uncompressed (images use the RunLengthDecode filter). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod arabic; mod metrics; +/// A PDF export could not preserve the requested text. +#[derive(Clone, Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for Error {} + use std::fmt::Write as _; /// RGB colour, 0–255 per channel. @@ -149,7 +161,13 @@ impl Font { } /// Width of `text` in points when set in `font` at `size` points. +/// Arabic uses shaped advances. If its font cannot render the text, measurement returns zero +/// and drawing that text causes [`PdfDoc::finish`] to return the rendering error. pub fn text_width(font: Font, size: f32, text: &str) -> f32 { + if arabic::needed(text) { + // Drawing records the error and finish rejects the document; measuring never substitutes ?. + return arabic::layout(font, text).map(|layout| layout.width * size / 1000.0).unwrap_or(0.0); + } // u64: a u32 sum overflows for ~8 million characters. let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum(); units as f32 * size / 1000.0 @@ -323,6 +341,8 @@ pub struct Page { images: Vec, links: Vec, depth: u32, + arabic: arabic::Subset, + error: Option, } /// Most bytes of content per page before further drawing is dropped (keeps hostile inputs from @@ -541,6 +561,21 @@ impl Page { self.op(&s); } + /// Arabic ink extents above and below the baseline, in points. Standard font text returns + /// `None`. Font errors are recorded for [`PdfDoc::finish`], just as when drawing text. + pub fn text_vertical_extents(&mut self, font: Font, size: f32, text: &str) -> Option<(f32, f32)> { + if !arabic::needed(text) { + return None; + } + match arabic::vertical_extents(font, text) { + Ok((ascent, descent)) => Some((ascent * size / 1000.0, descent * size / 1000.0)), + Err(error) => { + self.error.get_or_insert(error); + None + } + } + } + /// Text with its baseline starting at `(x, y)`. pub fn text(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str) { self.text_rotated(x, y, size, font, color, text, 0.0); @@ -552,9 +587,40 @@ impl Page { if text.is_empty() || !extent(size) || !finite(&[x, y]) { return; } + let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; + if arabic::needed(text) { + match self.arabic.text(font, text, size.min(10_000.0)) { + Ok(body) => { + for slot in [Some(font.index()), self.arabic.uses_cid.then_some(CID_FONT)].into_iter().flatten() { + if let Some(f) = self.fonts.get_mut(slot) { + *f = true; + } + } + let s = format!( + "q\n{} {} {} {} {} {} cm\n{} {}\n{body}\nQ", + n(cs), + n(-sn), + n(sn), + n(cs), + n(x), + n(self.y(y)), + rgb_op(color, false), + rgb_op(color, true) + ); + if self.content.len().saturating_add(s.len()) > MAX_PAGE_CONTENT { + self.error.get_or_insert_with(|| Error("PDF page text exceeds the size limit".into())); + } else { + self.op(&s); + } + } + Err(error) => { + self.error.get_or_insert(error); + } + } + return; + } let fi = font.index(); let size = n(size.min(10_000.0)); - let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; // Clockwise in y-down = counter-clockwise negated in PDF's y-up space. let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(x), n(self.y(y))); // Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj` @@ -677,7 +743,17 @@ impl PdfDoc { pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page { let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 }; let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 }; - self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 }); + self.pages.push(Page { + width, + height, + content: String::new(), + fonts: [false; FONT_SLOTS], + images: Vec::new(), + links: Vec::new(), + depth: 0, + arabic: arabic::Subset::default(), + error: None, + }); let i = self.pages.len() - 1; &mut self.pages[i] } @@ -692,7 +768,13 @@ impl PdfDoc { } /// Serializes the document. A document without pages gets one blank Letter page. - pub fn finish(mut self) -> Vec { + /// Returns an error if Arabic text could not be rendered without losing characters. + pub fn finish(mut self) -> Result, Error> { + for page in &self.pages { + if let Some(error) = &page.error { + return Err(error.clone()); + } + } if self.pages.is_empty() { self.add_page(612.0, 792.0); } @@ -754,6 +836,8 @@ impl PdfDoc { while page.depth > 0 { page.restore(); } + let arabic_fonts = page.arabic.objects(&mut next, &mut objects); + let page_fonts = format!("{font_dict}{arabic_fonts}"); let mut xobjects = String::new(); for (k, img) in page.images.iter().enumerate() { let img_id = next; @@ -817,7 +901,7 @@ impl PdfDoc { "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R{} >>", n(page.width), n(page.height), - font_dict, + page_fonts, xo, content_id, annots @@ -874,7 +958,7 @@ impl PdfDoc { } let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref); out.extend_from_slice(x.as_bytes()); - out + Ok(out) } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e540685d01..1b75e4c428 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -37,7 +37,7 @@ fn check_xref(pdf: &[u8]) -> usize { #[test] fn minimal_document() { - let pdf = PdfDoc::new().finish(); + let pdf = PdfDoc::new().finish().unwrap(); assert!(pdf.starts_with(b"%PDF-1.7")); assert!(pdf.ends_with(b"%%EOF\n")); assert!(check_xref(&pdf) >= 5); @@ -62,7 +62,7 @@ fn drawing_and_text() { let p2 = d.add_page(842.0, 595.0); p2.text(10.0, 20.0, 10.0, Font::CourierBold, [0, 0, 0], "Second"); assert_eq!(d.page_count(), 2); - let pdf = d.finish(); + let pdf = d.finish().unwrap(); check_xref(&pdf); assert!(find(&pdf, b"(Hello \\(world\\) \\\\ caf\\351 \\2005) Tj").is_some()); assert!(find(&pdf, b"(Second) Tj").is_some()); @@ -87,7 +87,7 @@ fn uri_and_page_links_are_real_pdf_annotations() { first.link_uri(0.0, 0.0, 10.0, 10.0, "javascript:alert(1)"); let second = d.add_page(100.0, 200.0); second.link_page(0.0, 0.0, 10.0, 10.0, 99, 0.0, 0.0); - let pdf = d.finish(); + let pdf = d.finish().unwrap(); check_xref(&pdf); let s = String::from_utf8_lossy(&pdf); assert_eq!(s.matches("/Subtype /Link").count(), 3); @@ -110,7 +110,7 @@ fn images_with_alpha() { p.image_rgba(10.0, 10.0, 40.0, 40.0, &px, 4, 4); p.image_rgba(10.0, 10.0, 40.0, 40.0, &[255; 16], 2, 2); // opaque: no mask p.image_rgba(0.0, 0.0, 1.0, 1.0, &[1, 2, 3], 4, 4); // too short: ignored - let pdf = d.finish(); + let pdf = d.finish().unwrap(); check_xref(&pdf); assert!(find(&pdf, b"/SMask").is_some()); assert!(find(&pdf, b"/Im1 Do").is_some()); @@ -177,7 +177,7 @@ fn hostile_inputs_never_panic() { p.restore(); p.save(); p.image_rgba(0.0, 0.0, 10.0, 10.0, &[], u32::MAX, u32::MAX); - let pdf = d.finish(); + let pdf = d.finish().unwrap(); check_xref(&pdf); } @@ -257,7 +257,7 @@ fn every_draw_method_survives_hostile_numbers() { p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]); let c = page_content(&d); assert!(!c.contains("NaN") && !c.contains("inf")); - let pdf = d.finish(); + let pdf = d.finish().unwrap(); check_xref(&pdf); } @@ -277,7 +277,7 @@ fn info_strings_with_non_ascii_are_utf16be() { doc.add_page(200.0, 200.0); doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into(); doc.info.author = "Müller & Søn".into(); - let pdf = doc.finish(); + let pdf = doc.finish().unwrap(); assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀"); assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn"); } @@ -287,7 +287,7 @@ fn ascii_info_strings_stay_literal_and_escaped() { let mut doc = PdfDoc::new(); doc.add_page(200.0, 200.0); doc.info.title = r"Q1 (draft) a\b".into(); - let pdf = doc.finish(); + let pdf = doc.finish().unwrap(); assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some()); assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some()); } @@ -297,7 +297,7 @@ fn info_strings_are_capped() { let mut doc = PdfDoc::new(); doc.add_page(200.0, 200.0); doc.info.title = "日".repeat(100_000); - let pdf = doc.finish(); + let pdf = doc.finish().unwrap(); assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS); } @@ -307,7 +307,7 @@ fn cjk_text_is_not_replaced_by_question_marks() { let p = doc.add_page(612.0, 792.0); p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], "签字清单"); p.text(10.0, 40.0, 10.0, Font::Helvetica, [0, 0, 0], "No. 编号 2"); - let pdf = doc.finish(); + let pdf = doc.finish().unwrap(); check_xref(&pdf); assert!(find(&pdf, b"(????) Tj").is_none(), "CJK text written as question marks"); assert!(find(&pdf, b"<7B7E5B576E055355> Tj").is_some()); @@ -318,3 +318,166 @@ fn cjk_text_is_not_replaced_by_question_marks() { // Measured as full-width glyphs, matching the CID font's default width. assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0); } + +#[test] +fn arabic_is_shaped_and_preserved_or_export_reports_missing_font() { + let text = "مَرْحَبًا 123 (ABC)"; + let mut doc = PdfDoc::new(); + let page = doc.add_page(600.0, 300.0); + page.text(30.0, 70.0, 20.0, Font::Helvetica, [0, 0, 0], text); + let layout = arabic::layout(Font::Helvetica, text); + let Ok(layout) = layout else { + let error = doc.finish().unwrap_err().to_string(); + assert!(error.contains("Arabic PDF"), "{error}"); + return; + }; + assert!(layout.width > 0.0); + assert!((text_width(Font::Helvetica, 20.0, text) - layout.width * 0.02).abs() < 0.001); + let pdf = doc.finish().unwrap(); + check_xref(&pdf); + assert_eq!(utf16_entry(&pdf, "/ActualText"), text); + assert!(find(&pdf, b"/Subtype /Type3").is_some()); + assert!(find(&pdf, b"/ToUnicode").is_some()); + assert!(find(&pdf, b"beginbfchar").is_some()); + assert!(find(&pdf, b"(?) Tj").is_none()); + assert!(find(&pdf, b"/UniGB-UTF16-H").is_none(), "Arabic is never set in the CJK font"); +} + +#[test] +fn latin_inside_arabic_text_declares_its_page_font() { + if arabic::layout(Font::Helvetica, "مرحبا").is_err() && arabic::ARABIC_FONTS.is_empty() { + return; // Optional font input; required-font CI exercises this regression. + } + let mut doc = PdfDoc::new(); + doc.add_page(600.0, 300.0).text(30.0, 70.0, 20.0, Font::Helvetica, [0, 0, 0], "مرحبا\u{2028}عالم"); + let pdf = doc.finish().unwrap(); + check_xref(&pdf); + assert!(find(&pdf, b"/F1 20 Tf").is_some(), "the line break is drawn as a Helvetica space"); + assert!(find(&pdf, b"/Font << /F1 ").is_some(), "the page declares the font its Latin run uses"); +} + +#[test] +fn cjk_inside_arabic_text_uses_the_cid_font() { + if arabic::layout(Font::Helvetica, "مرحبا").is_err() && arabic::ARABIC_FONTS.is_empty() { + return; // Optional font input; required-font CI exercises this regression. + } + let text = "مرحبا 编号"; + let mut doc = PdfDoc::new(); + doc.add_page(600.0, 300.0).text(30.0, 70.0, 20.0, Font::Helvetica, [0, 0, 0], text); + let pdf = doc.finish().unwrap(); + check_xref(&pdf); + assert!(find(&pdf, b"<7F1653F7> Tj").is_some(), "the CJK run is set in the CID font"); + assert!(find(&pdf, b"/Encoding /UniGB-UTF16-H").is_some(), "the page declares the CID font"); + assert_eq!(utf16_entry(&pdf, "/ActualText"), text); + assert!(text_width(Font::Helvetica, 20.0, text) >= text_width(Font::Helvetica, 20.0, "مرحبا") + 40.0, "two full-width ideographs"); + let mut emoji = PdfDoc::new(); + emoji.add_page(600.0, 300.0).text(30.0, 70.0, 20.0, Font::Helvetica, [0, 0, 0], "مرحبا 😀"); + assert!(emoji.finish().is_err(), "characters no font covers still fail the export"); +} + +#[test] +fn oversized_arabic_text_is_an_error_instead_of_partial_pdf() { + let mut doc = PdfDoc::new(); + doc.add_page(600.0, 300.0).text(0.0, 50.0, 10.0, Font::Helvetica, [0, 0, 0], &"ب".repeat(65_537)); + assert!(doc.finish().unwrap_err().to_string().contains("128 KiB")); +} + +#[test] +fn arabic_joining_marks_styles_and_rotation() { + if arabic::layout(Font::Helvetica, "مرحبا").is_err() && arabic::ARABIC_FONTS.is_empty() { + return; // Optional font input; required-font CI exercises this regression. + } + for (text, expected) in [("مرحبا 123 (ABC)", "(ABC)"), ("مرحبا [ABC]", "[ABC]"), ("مرحبا ", "")] { + let mixed = arabic::layout(Font::Helvetica, text).unwrap(); + let latin: String = mixed + .items + .iter() + .filter_map(|item| match item { + arabic::Item::Latin { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect(); + if !latin.is_empty() { + assert_eq!(latin, expected); + } + } + let mut metrics = PdfDoc::new(); + let (ascent, descent) = metrics.add_page(100.0, 100.0).text_vertical_extents(Font::Helvetica, 20.0, "كمثرى").unwrap(); + assert!(ascent > 0.0 && descent > 20.0 * 0.22, "Arabic descenders exceed the old Latin baseline allowance"); + assert!( + metrics.page_mut(0).unwrap().text_vertical_extents(Font::Helvetica, 20.0, "مرحبا 123 (ABC)").is_some(), + "spaces in variable fonts have empty outlines" + ); + metrics.finish().unwrap(); + let joined = arabic::layout(Font::Helvetica, "سلام").unwrap(); + let separate_width: f32 = "سلام".chars().map(|c| text_width(Font::Helvetica, 1000.0, &c.to_string())).sum(); + assert!((joined.width - separate_width).abs() > 10.0, "joining changes advances"); + let marked = arabic::layout(Font::Helvetica, "مَرْحَبًا").unwrap(); + let plain = arabic::layout(Font::Helvetica, "مرحبا").unwrap(); + assert!(marked.items.len() > plain.items.len()); + assert!(marked.items.iter().any(|item| matches!(item, arabic::Item::Glyph { y, .. } if *y != 0.0))); + let bold_available = match arabic::layout(Font::HelveticaBold, "مرحبا") { + Ok(bold) if arabic::bold_face_is_synthetic() => { + assert!(bold.width >= plain.width, "synthetic bold keeps the regular advances"); + true + } + Ok(bold) => { + assert!(bold.width > plain.width, "bold metrics use the bold font instance"); + true + } + Err(e) => { + assert!(arabic::ARABIC_FONTS.is_empty(), "a bundled face always serves bold text: {e:?}"); + false + } + }; + let mut regular = PdfDoc::new(); + regular.add_page(500.0, 300.0).text(30.0, 60.0, 20.0, Font::Helvetica, [0, 0, 0], "مرحبا"); + let regular = regular.finish().unwrap(); + let mut italic = PdfDoc::new(); + italic.add_page(500.0, 300.0).text(30.0, 60.0, 20.0, Font::HelveticaOblique, [0, 0, 0], "مرحبا"); + let italic = italic.finish().unwrap(); + assert_ne!(regular, italic, "italic shears the actual outlines"); + let rotated_font = if bold_available { Font::HelveticaBoldOblique } else { Font::HelveticaOblique }; + let mut rotated = PdfDoc::new(); + rotated.add_page(500.0, 300.0).text_rotated(100.0, 100.0, 20.0, rotated_font, [0, 0, 0], "مرحبا 123", -std::f32::consts::FRAC_PI_2); + let rotated = rotated.finish().unwrap(); + check_xref(&rotated); + assert_eq!(utf16_entry(&rotated, "/ActualText"), "مرحبا 123"); + assert!(find(&rotated, b"0 1 -1 0 100 200 cm").is_some()); +} + +#[test] +fn line_breaks_in_arabic_text_draw_as_spaces() { + if arabic::layout(Font::Helvetica, "مرحبا").is_err() && arabic::ARABIC_FONTS.is_empty() { + return; // Optional font input; required-font CI exercises this regression. + } + for text in ["مرحبا\r\nعالم", "مرحبا\u{2028}عالم", "مرحبا\u{2029}ABC\r"] { + let layout = arabic::layout(Font::Helvetica, text).unwrap(); + for item in &layout.items { + if let arabic::Item::Latin { text: drawn, .. } = item { + assert!(!drawn.chars().any(|ch| matches!(ch, '\n' | '\r' | '\u{2028}' | '\u{2029}')), "{drawn:?} from {text:?}"); + } + } + } + let drawn = |text: &str| format!("{:?}", arabic::layout(Font::Helvetica, text).unwrap().items); + assert_eq!(drawn("مرحبا\u{2029}عالم"), drawn("مرحبا عالم"), "a paragraph separator keeps right-to-left reading order"); +} + +#[test] +fn arabic_subsets_split_after_255_character_codes() { + if arabic::layout(Font::Helvetica, "مرحبا").is_err() && arabic::ARABIC_FONTS.is_empty() { + return; // Optional font input; required-font CI exercises this regression. + } + let mut doc = PdfDoc::new(); + let page = doc.add_page(600.0, 300.0); + let marks = ['َ', 'ُ', 'ِ', 'ّ', 'ْ', 'ً', 'ٌ', 'ٍ']; + for i in 0..260 { + let text = format!("ب{}{}{}", marks[i % 8], marks[(i / 8) % 8], marks[(i / 64) % 8]); + page.text(20.0, 50.0, 10.0, Font::Helvetica, [0, 0, 0], &text); + } + let pdf = doc.finish().unwrap(); + check_xref(&pdf); + assert!(find(&pdf, b"/A2 ").is_some()); + assert!(find(&pdf, b"/LastChar 255").is_some()); + assert!(find(&pdf, b"<100> Tj").is_none(), "Type3 codes are always one byte"); +} diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 343bfa9eed..f94d99fb92 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -477,7 +477,7 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { + wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::(); assert!(kept <= one + VML.len() / 2, "kept {kept} bytes"); assert!(wb.sheet(0).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some())); - assert!(wb.sheet(1).unwrap().preserved.as_ref().is_none_or(|p| p.vml.is_none())); + assert!(wb.sheet(1).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_none())); assert!(rep.warnings.iter().any(|w| w.contains("\"Other\"") && w.contains("too large")), "{:?}", rep.warnings); // Both sheets keep their drawings when there is room. let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf567f8bd6..bfff20ec59 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (Arabic PDF text is shaped, embedded and searchable; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -14,6 +14,8 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when shared formulas are expanded during import (`array_export.rs`, #1505). +Arabic PDF output uses harfrust shaping, Unicode bidi and licensed Noto Arabic glyph outlines with ToUnicode and logical ActualText. Cells, headers and chart labels support marks, digits, rotation and style. Bundled builds use the optional `CRAFT_FONTS_DIR` input; unbundled native builds look for Noto Sans Arabic. Unsupported text fails export before writing the destination. Explicit cell reading-direction integration is separate. + ## Workbook formats XLSX column declarations are limited to 1,000,000 expanded column assignments per worksheet, @@ -81,7 +83,7 @@ values) is still dropped. | Format | Excel | GridCraft | Notes | |---|---|---|---| -| PDF | Save As / Export | yes (own PDF 1.7 writer; CLI `convert` output) | Printed cell hyperlinks export as clickable HTTP(S)/mailto links or destinations within printed pages (#954); unsafe schemes and destinations outside the print range are omitted. Only the 14 standard PDF fonts, no embedding: **non-Latin text (CJK, Arabic, Devanagari, Thai) does not export**; web build exports without a file prompt (#141) | +| PDF | Save As / Export | yes (own PDF 1.7 writer; CLI `convert` output) | Printed cell hyperlinks export as clickable HTTP(S)/mailto links or destinations within printed pages (#954); unsafe schemes and destinations outside the print range are omitted. Latin text uses the 14 standard PDF fonts; Arabic exports as shaped, embedded Noto Arabic glyphs; other characters outside WinAnsiEncoding (CJK) use a predefined, non-embedded Adobe-GB1 CID font (#1912); **Devanagari and Thai do not export yet**; web build exports without a file prompt (#141) | | XPS | Windows | no | Low priority | | Print | yes | yes (via PDF, system dialog on desktop; PR #143 for web) | | | Images of ranges/charts (Copy as Picture) | yes | chart raster in `crates/chart` | No range-to-image command | @@ -110,6 +112,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Arabic PDF export embeds licensed glyph outlines, joins letters and marks, preserves logical text and reports unsupported glyphs before writing; Arabic runs take the embedded faces ahead of the CJK CID font, and CJK inside Arabic text is set in the CID font instead of failing the export | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 39a9b3f91e..9065c5a9f0 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,14 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Arabic PDF export joins letters and preserves logical text; missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, right-to-left sheets and full complex-script support. Part of [target-app-parity.md](target-app-parity.md). **Localization: ~10% ready, 100–165 h** (estimated). +Arabic PDF output uses harfrust shaping, Unicode bidi and licensed Noto Arabic glyph outlines with ToUnicode and logical ActualText. Cells, headers and chart labels support marks, digits, rotation and style. Bundled builds use the optional `CRAFT_FONTS_DIR` input; unbundled native builds look for Noto Sans Arabic. Unsupported text fails export before writing the destination. Explicit cell reading-direction integration is separate. + ## Where we are (measured from source on `main`, after the 2026-10-10 merges) - **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in @@ -46,8 +48,9 @@ right-to-left sheets and full complex-script support. Part of `/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant. This does not change the installed-font catalogue or add bundled fonts. Lao falls back to Lao Sangam MN / Lao MN (macOS), - Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). There is no complex-text shaping: Arabic and - Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left** + Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). The grid and editors have no complex-text shaping: + Arabic and Devanagari don't join or reorder there, and there is no bidirectional layout. Arabic + PDF export is the exception: it is shaped and bidi-ordered (see above). **Sheet Right-to-Left** (`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`, `.xls` WINDOW2 fRightToLeft) mirrors the grid (#146): column A and the row headers on the right, the vertical scrollbar on the left; hit-testing, arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box @@ -58,7 +61,8 @@ right-to-left sheets and full complex-script support. Part of - **Names in any script.** Defined names (Define Name, Create from Selection) are limited to 255 characters, counted as characters rather than UTF-8 bytes, so a 255-letter Arabic name is accepted as in Excel. Sheet names already counted characters (31). -- **PDF export** uses only the 14 standard PDF fonts: non-Latin text cannot export. +- **PDF export** shapes Arabic with embedded Noto Arabic glyph outlines; CJK uses a predefined, + non-embedded Adobe-GB1 CID font (#1912); Devanagari and Thai cannot export yet. ## Languages @@ -72,7 +76,7 @@ separators, number/date formats, machine translation with agent review, and scri | Simplified Chinese (Mandarin) | zh-Hans | 266 (~12%) | no | CJK system-font fallback (#38); IME unverified; PDF can't export | no | menus only | 8–13 h | | Spanish | es | 0 (0%) | no | Latin: yes | no | none | 6–10 h | | Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h | -| Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | +| Arabic | ar | 0 (0%) | no | grid and editor: **no shaping, no RTL UI**; PDF export: shaped and joined; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h | | Portuguese | pt-BR | 943 (~43%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h | @@ -110,6 +114,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Arabic PDF cells, headers and chart labels support joined letters, combining marks, digits, bold/italic, rotation and grapheme wrapping | | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | From 8a96504dbf0d77b7be33bc87dc968fd6fc9131de Mon Sep 17 00:00:00 2001 From: mouhand kaddo Date: Tue, 13 Oct 2026 13:02:49 +0400 Subject: [PATCH 02/12] M12.AR5: preserve cell direction and RTL sheet geometry in PDF export --- ROADMAP.md | 4 +- crates/engine/src/cf.rs | 25 ++ crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/cmd/print.rs | 393 +++++++++++++++++++++++------ crates/engine/src/tests.rs | 38 +++ crates/model/src/lib.rs | 4 +- crates/model/src/style.rs | 30 +++ crates/pdf/src/arabic.rs | 41 ++- crates/pdf/src/lib.rs | 93 ++++++- crates/pdf/src/tests.rs | 131 +++++++++- crates/xlsx/src/styles.rs | 62 ++++- crates/xlsx/src/tests/roundtrip.rs | 10 +- docs/file-format-parity.md | 7 +- docs/gaps.md | 7 +- docs/localization-parity.md | 13 +- 15 files changed, 729 insertions(+), 131 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9abbf7fea6..7ab539ea4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (Arabic PDF text is shaped and searchable; AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (PDF honors cell direction and RTL sheet geometry; Arabic PDF text is shaped and searchable; AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-26 | PDF preserves Context/LTR/RTL reading direction through wrapping and drawing; RTL sheet cells, borders, headings, objects and sparklines mirror consistently | | 2026-10-26 | Arabic PDF export shapes licensed glyph outlines and preserves searchable logical text; missing or unsupported glyphs return an error before overwriting output | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | @@ -238,6 +239,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Progress log: PDF reading direction and RTL sheets | | 2026-10-26 | minor | Progress log: Arabic PDF text | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-23 | minor | Progress log: accounting negatives in number text | diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 0c0f9683ea..59f8f421d9 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -350,6 +350,9 @@ impl CfCache { /// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts /// are unset). fn layer_under(style: &mut Style, lower: &Style) { + if style.align.reading_order.is_none() { + style.align.reading_order = lower.align.reading_order; + } if style.fill.pattern == PatternType::None { style.fill = lower.fill; } @@ -399,6 +402,28 @@ mod tests { use super::*; + #[test] + fn reading_order_respects_conditional_rule_priority_and_explicit_context() { + for (higher, expected) in [ + (json!({"font": {"bold": true}}), "RightToLeft"), + (json!({"align": {"reading_order": "Context"}}), "Context"), + (json!({"align": {"reading_order": "LeftToRight"}}), "LeftToRight"), + ] { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap(); + for style in [json!({"align": {"reading_order": "RightToLeft"}}), higher] { + s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "noBlanks", "style": style}})).unwrap(); + } + let wb = &s.doc().unwrap().wb; + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap(); + for wb in [wb, &reopened] { + let style = CfCache::new().look(wb, 0, CellRef::new(0, 0)).unwrap().style.unwrap(); + assert_eq!(serde_json::to_value(style.align).unwrap()["reading_order"], expected); + } + } + } + #[test] fn every_matching_rule_applies() { let mut s = crate::Session::new(); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 691b531c70..7e75775273 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "Format Cells…", ["Home", "Cells", "Format"], Some("Cmd+1"), - "{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)", + "{range?, style: {font?, fill?, borders?, align?: {reading_order?: Context|LeftToRight|RightToLeft|null, ...}, numFmt?, protection?}} (partial Style JSON merged in; null clears reading_order)", has_doc, format_cells ), diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index c21d95dc1e..b45f76db8c 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -11,7 +11,7 @@ use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::*; -use gridcraft_pdf::{Font, Page, PdfDoc, Rgb}; +use gridcraft_pdf::{Font, Page, PdfDoc, Rgb, TextDirection}; use serde_json::{Value as Json, json}; use unicode_segmentation::UnicodeSegmentation; @@ -660,6 +660,9 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option Vec { +struct TextLine { + text: String, + direction: TextDirection, +} + +/// Word-wraps paragraphs, keeping their base direction on every continuation line. +fn wrap_lines(text: &str, font: Font, size: f32, width: f32, direction: TextDirection) -> Vec { let mut out = Vec::new(); for para in text.split('\n') { + let direction = direction.resolve(para); + let measured = |text: &str| gridcraft_pdf::text_width_with_direction(font, size, text, direction); let mut line = String::new(); for word in para.split(' ') { let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { + if line.is_empty() || measured(&cand) <= width { line = cand; } else { - out.push(std::mem::take(&mut line)); + out.push(TextLine { text: std::mem::take(&mut line), direction }); line = word.to_string(); } - // Keep marks with their base letter. Binary search avoids shaping every prefix of - // a long Arabic word; each emitted prefix is measured in its final joined form. - while gridcraft_pdf::text_width(font, size, &line) > width { + // Keep marks with their base letter. Each line's split is searched from its own start, + // so a long word costs about its emitted lines, each measured in its final joined form. + if measured(&line) > width { let boundaries: Vec = line.grapheme_indices(true).map(|(i, _)| i).chain(std::iter::once(line.len())).collect(); - let count = boundaries.len().saturating_sub(1); - if count <= 1 { - break; - } - let (mut low, mut high, mut split) = (1, count - 1, 1); - while low <= high { - let mid = low + (high - low) / 2; - let end = boundaries.get(mid).copied().unwrap_or(line.len()); - let prefix = line.get(..end).unwrap_or_default(); - if gridcraft_pdf::text_width(font, size, prefix) <= width { - split = mid; - low = mid + 1; - } else { - high = mid - 1; + let graphemes = boundaries.len().saturating_sub(1); + let piece = + |from: usize, to: usize| boundaries.get(from).zip(boundaries.get(to)).and_then(|(&a, &b)| line.get(a..b)).unwrap_or_default(); + let mut start = 0; + while graphemes - start > 1 { + let left = graphemes - start; + let fits = |n: usize| measured(piece(start, start + n)) <= width; + let mut split = 1; + while split < left && fits((split * 2).min(left)) { + split = (split * 2).min(left); + } + if split == left { + break; + } + let (mut low, mut high) = (split + 1, (split * 2).min(left) - 1); + while low <= high { + let mid = low + (high - low) / 2; + if fits(mid) { + split = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + out.push(TextLine { text: piece(start, start + split).to_string(), direction }); + start += split; + if out.len() > 2000 { + return out; } } - let end = boundaries.get(split).copied().unwrap_or(line.len()); - out.push(line.get(..end).unwrap_or_default().to_string()); - line = line.get(end..).unwrap_or_default().to_string(); - if out.len() > 2000 { - return out; - } + line = piece(start, graphemes).to_string(); } if out.len() > 2000 { return out; } } - out.push(line); + out.push(TextLine { text: line, direction }); } out } @@ -851,9 +870,20 @@ struct Block<'a> { ys: Vec, k: f32, scale: f32, + mirror_axis: f32, } impl Block<'_> { + fn point_x(&self, x: f32) -> f32 { + if self.sh.right_to_left { self.mirror_axis - x } else { x } + } + fn text_x(&self, x: f32, width: f32) -> f32 { + if self.sh.right_to_left { self.mirror_axis - x - width } else { x } + } + fn drawn_rect(&self, r: RangeRef) -> (f32, f32, f32, f32) { + let (x, y, w, h) = self.rect(r); + (self.text_x(x, w), y, w, h) + } fn x(&self, col: u32) -> f32 { if col >= self.cols.0 && col <= self.cols.1 + 1 { return self.xs.get((col - self.cols.0) as usize).copied().unwrap_or(0.0); @@ -933,15 +963,15 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option (bd.right, bd.bottom), }; - draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k); - draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k); - draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k); - draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k); - draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k); - draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k); + draw_border(pg, &bd.top, b.wb, b.point_x(x), y, b.point_x(x + w), y, b.k); + draw_border(pg, &bottom, b.wb, b.point_x(x), y + h, b.point_x(x + w), y + h, b.k); + draw_border(pg, &bd.left, b.wb, b.point_x(x), y, b.point_x(x), y + h, b.k); + draw_border(pg, &right, b.wb, b.point_x(x + w), y, b.point_x(x + w), y + h, b.k); + draw_border(pg, &bd.diag_down, b.wb, b.point_x(x), y, b.point_x(x + w), y + h, b.k); + draw_border(pg, &bd.diag_up, b.wb, b.point_x(x), y + h, b.point_x(x + w), y, b.k); } // Text. for (c, l) in &looks { @@ -1024,7 +1054,7 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option, } /// PDF annotations are not affected by graphics clipping, so intersect each cell with its page @@ -1065,7 +1097,7 @@ fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri), crate::links::LinkKind::Internal(target) if !link.external => { @@ -1084,6 +1116,10 @@ fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, t let sh = wb.sheet(sheet)?; let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?; let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k; + let x = match block.mirror_axis { + Some(axis) => axis - x - (sh.col_left(cell.col.saturating_add(1)) - sh.col_left(cell.col)) as f32 * block.k, + None => x, + }; let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k; Some((block.page, x, y)) } @@ -1177,15 +1213,21 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 if text.is_empty() { return; } - let mut tw = gridcraft_pdf::text_width(font, size, &text); + let direction = match st.align.reading_order.unwrap_or_default() { + ReadingOrder::Context => TextDirection::Context, + ReadingOrder::LeftToRight => TextDirection::LeftToRight, + ReadingOrder::RightToLeft => TextDirection::RightToLeft, + } + .resolve(&text); + let mut tw = gridcraft_pdf::text_width_with_direction(font, size, &text, direction); if st.align.shrink && !wrap && tw > avail && tw > 0.0 { size = (size * avail / tw).max(1.0); - tw = gridcraft_pdf::text_width(font, size, &text); + tw = gridcraft_pdf::text_width_with_direction(font, size, &text, direction); } if is_num && !wrap && tw > avail + 0.01 && !sh.show_formulas { let hash = gridcraft_pdf::text_width(font, size, "#").max(0.1); text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255)); - tw = gridcraft_pdf::text_width(font, size, &text); + tw = gridcraft_pdf::text_width_with_direction(font, size, &text, direction); } let h_align = match st.align.h { HAlign::General => { @@ -1193,6 +1235,8 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 HAlign::Right } else if matches!(v, Value::Bool(_) | Value::Error(_)) { HAlign::Center + } else if direction == TextDirection::RightToLeft && !sh.right_to_left { + HAlign::Right } else { HAlign::Left } @@ -1204,15 +1248,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let rot = st.align.rotation; let rotated = rot != 0 && rot != 255; // Rotated text wraps along its own direction: upright (±90°) text to the row height. - let lines: Vec = match (wrap, rotated) { - (true, false) => wrap_lines(&text, font, size, avail.max(size)), - (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), - (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], - (false, _) => vec![text], + let lines: Vec = match (wrap, rotated) { + (true, false) => wrap_lines(&text, font, size, avail.max(size), direction), + (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size), direction), + (true, true) => vec![TextLine { text: text.lines().next().unwrap_or_default().to_string(), direction }], + (false, _) => vec![TextLine { text, direction }], }; let extents = lines .iter() - .filter_map(|line| pg.text_vertical_extents(font, size, line)) + .filter_map(|line| pg.text_vertical_extents_with_direction(font, size, &line.text, line.direction)) .reduce(|a, b| (a.0.max(b.0), a.1.max(b.1))) .map(|(ascent, descent)| (ascent.max(size * 0.8), descent.max(size * 0.22))); let line_h = extents.map_or(size * 1.2, |(ascent, descent)| (ascent + descent + size * 0.2).max(size * 1.2)); @@ -1267,7 +1311,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let clip_l = clip_l.max(limits.0); let clip_r = clip_r.min(limits.1); pg.save(); - pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); + pg.clip_rect(b.text_x(clip_l, clip_r - clip_l), y, (clip_r - clip_l).max(0.0), h); if rotated { // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. // Wrapped lines stack rightward for upward text and leftward for downward text, so @@ -1282,19 +1326,23 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 } else { (x + pad + (last - i) * line_h, y + pad + extents.map_or(size * 0.8, |e| e.0)) }; - pg.text_rotated(ox, oy, size, font, color, t, angle); + let tw = gridcraft_pdf::text_width_with_direction(font, size, &t.text, t.direction); + let (sn, cs) = angle.sin_cos(); + let (ascent, descent) = extents.unwrap_or((size * 0.8, size * 0.22)); + let ox = if sh.right_to_left { b.mirror_axis - ox - tw * cs - (ascent - descent) * sn } else { ox }; + pg.text_rotated_with_direction(ox, oy, size, font, color, &t.text, angle, t.direction); } pg.restore(); return; } if rot == 255 { // Vertical stacked text. - let stacked = lines.concat(); + let stacked: String = lines.iter().map(|line| line.text.as_str()).collect(); let chars: Vec<&str> = stacked.graphemes(true).take(500).collect(); let mut yy = y + pad + extents.map_or(size * 0.8, |e| e.0); for ch in chars { let cw = gridcraft_pdf::text_width(font, size, ch); - pg.text(x + (w - cw) / 2.0, yy, size, font, color, ch); + pg.text_with_direction(b.text_x(x + (w - cw) / 2.0, cw), yy, size, font, color, ch, direction); yy += line_h; if yy > y + h + line_h { break; @@ -1304,7 +1352,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 return; } for (i, line) in lines.iter().enumerate() { - let lw = gridcraft_pdf::text_width(font, size, line); + let lw = gridcraft_pdf::text_width_with_direction(font, size, &line.text, line.direction); let lx = match h_align { HAlign::Right => x + w - pad - lw - indent, HAlign::Center => x + (w + across - lw) / 2.0, @@ -1314,7 +1362,8 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 if ly - size > y + h || ly < y - line_h { continue; } - pg.text(lx, ly, size, font, color, line); + let lx = b.text_x(lx, lw); + pg.text_with_direction(lx, ly, size, font, color, &line.text, line.direction); if st.font.underline != Underline::None { let uw = (size * 0.06).max(0.2); pg.stroke_line(lx, ly + size * 0.12, lx + lw, ly + size * 0.12, uw, color); @@ -1364,9 +1413,11 @@ fn draw_objects( let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); let visible = |r: (f64, f64, f64, f64)| r.2 > 0.0 && r.3 > 0.0 && r.0 < sx1 && r.0 + r.2 > sx0 && r.1 < sy1 && r.1 + r.3 > sy0; - let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k); + let place = |r: (f64, f64, f64, f64)| { + (b.text_x(bx + ((r.0 - sx0) as f32) * b.k, r.2 as f32 * b.k), by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k) + }; pg.save(); - pg.clip_rect(bx, by, bw, bh); + pg.clip_rect(b.text_x(bx, bw), by, bw, bh); draw_sparklines(pg, wb, si, sh, b, block); for (imi, im) in sh.images.iter().enumerate() { let r = anchor_rect(sh, &im.anchor); @@ -1412,9 +1463,20 @@ fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Bloc v => vec![v.as_f64()], }; let color = rgb_of(sp.color, wb).unwrap_or([0x15, 0x60, 0x82]); - let (x, y, w, h) = b.rect(RangeRef::cell(sp.cell)); + let (x, y, w, h) = b.drawn_rect(RangeRef::cell(sp.cell)); let (wpx, hpx) = (w / b.k, h / b.k); - let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); + let mut prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); + // In-cell drawings reflect with the grid; floating charts move as a whole. + if sh.right_to_left { + for prim in &mut prims { + match prim { + gridcraft_chart::Prim::Rect { x, w, .. } => *x = wpx - *x - *w, + gridcraft_chart::Prim::Line { pts, .. } => pts.iter_mut().for_each(|p| p[0] = wpx - p[0]), + gridcraft_chart::Prim::Circle { cx, .. } => *cx = wpx - *cx, + _ => {} + } + } + } draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); } } @@ -1458,7 +1520,7 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); - let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); + let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size), TextDirection::Auto); let block_h = lines.len().min(500) as f32 * size * 1.2; let mut yy = match anchor { ShapeTextAnchor::Top => y + 2.0 * b.k, @@ -1466,13 +1528,13 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, } + size * 0.8; for l in lines.iter().take(500) { - let lw = gridcraft_pdf::text_width(font, size, l); + let lw = gridcraft_pdf::text_width_with_direction(font, size, &l.text, l.direction); let lx = match align { ShapeTextAlign::Left => x + 2.0 * b.k, ShapeTextAlign::Center => x + (w - lw) / 2.0, ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, }; - pg.text(lx, yy, size, font, color, l); + pg.text_with_direction(lx, yy, size, font, color, &l.text, l.direction); yy += size * 1.2; } } @@ -1489,11 +1551,11 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: if x1 - x0 <= 0.0 { continue; } - pg.fill_rect(x0, y, x1 - x0, size.1, fill); - pg.rect_stroke(x0, y, x1 - x0, size.1, lw, line); + pg.fill_rect(b.text_x(x0, x1 - x0), y, x1 - x0, size.1, fill); + pg.rect_stroke(b.text_x(x0, x1 - x0), y, x1 - x0, size.1, lw, line); let t = gridcraft_core::col_to_letters(col); let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); - pg.text(x0 + (x1 - x0 - tw) / 2.0, y + size.1 / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); + pg.text(b.text_x(x0 + (x1 - x0 - tw) / 2.0, tw), y + size.1 / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); } } if row_heads { @@ -1503,11 +1565,11 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: if y1 - y0 <= 0.0 { continue; } - pg.fill_rect(x, y0, size.0, y1 - y0, fill); - pg.rect_stroke(x, y0, size.0, y1 - y0, lw, line); + pg.fill_rect(b.text_x(x, size.0), y0, size.0, y1 - y0, fill); + pg.rect_stroke(b.text_x(x, size.0), y0, size.0, y1 - y0, lw, line); let t = (row + 1).to_string(); let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); - pg.text(x + (size.0 - tw) / 2.0, y0 + (y1 - y0) / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); + pg.text(b.text_x(x + (size.0 - tw) / 2.0, tw), y0 + (y1 - y0) / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); } } } @@ -1562,7 +1624,7 @@ fn make_block<'a>(wb: &'a Workbook, si: usize, sh: &'a Sheet, rows: (u32, u32), acc += sh.row_height(r) * k; ys.push(acc); } - Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale } + Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale, mirror_axis: lay.page_w } } /// Segments of a page along one axis: repeated titles (before the band) then the band. @@ -1618,7 +1680,16 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay); draw_block(pg, &b, lay.settings.gridlines, &mut cf); draw_block_links(pg, &b, page_index, &mut pending_links); - printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k }); + printed_blocks.push(PrintedBlock { + page: page_index, + sheet: lay.sheet, + rows: *rs, + cols: *cs, + x, + y, + k, + mirror_axis: sh.right_to_left.then_some(b.mirror_axis), + }); if lay.settings.headings { draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k)); } @@ -1634,6 +1705,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str } if lay.settings.headings { let fill = [0xF2, 0xF2, 0xF2]; + let ox = if sh.right_to_left { lay.page_w - ox - lay.heading_w * k } else { ox }; pg.fill_rect(ox, oy, lay.heading_w * k, lay.heading_h * k, fill); pg.rect_stroke(ox, oy, lay.heading_w * k, lay.heading_h * k, 0.3, [0xA0, 0xA0, 0xA0]); } @@ -1902,6 +1974,30 @@ mod tests { hay.windows(needle.len()).any(|w| w == needle.as_bytes()) } + #[test] + fn right_to_left_sheets_mirror_link_rectangles_and_targets() { + fn numbers_after(pdf: &[u8], key: &str, n: usize) -> Vec { + let text = String::from_utf8_lossy(pdf); + let at = text.find(key).unwrap() + key.len(); + text[at..].split([' ', ']']).filter_map(|t| t.parse().ok()).take(n).collect() + } + let mut s = s(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap(); + s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!B60"})).unwrap(); + s.execute("cell.set", json!({"cell": "B60", "input": "Destination"})).unwrap(); + let (ltr, _) = pdf_of(&mut s, json!({})); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + let (rtl, _) = pdf_of(&mut s, json!({})); + let axis = numbers_after(<r, "/MediaBox [", 4)[2]; + let (l, r) = (numbers_after(<r, "/Rect [", 4), numbers_after(&rtl, "/Rect [", 4)); + assert!((r[0] - (axis - l[2])).abs() < 0.01 && (r[2] - (axis - l[0])).abs() < 0.01, "{l:?} mirrors to {r:?}"); + assert_eq!(r[1], l[1]); + let b_width = s.doc().unwrap().wb.active().unwrap().col_width(1); + let (lx, rx) = (numbers_after(<r, "/XYZ ", 1)[0], numbers_after(&rtl, "/XYZ ", 1)[0]); + let k = (l[2] - l[0]) / s.doc().unwrap().wb.active().unwrap().col_width(0); + assert!((rx - (axis - lx - b_width * k)).abs() < 0.01, "target {lx} mirrors to {rx}"); + } + #[test] fn pdf_export_keeps_safe_links_on_their_printed_pages() { let mut s = s(); @@ -1941,9 +2037,9 @@ mod tests { #[test] fn wrapping_keeps_combining_marks_with_their_letters() { let word = "a\u{301}a\u{301}a\u{301}a\u{301}"; - let lines = wrap_lines(word, Font::Helvetica, 10.0, 7.0); - assert_eq!(lines.concat(), word); - assert!(lines.iter().all(|line| line == "a\u{301}")); + let lines = wrap_lines(word, Font::Helvetica, 10.0, 7.0, TextDirection::Auto); + assert_eq!(lines.iter().map(|line| line.text.as_str()).collect::(), word); + assert!(lines.iter().all(|line| line.text == "a\u{301}")); } #[test] @@ -1980,15 +2076,132 @@ mod tests { let utf16 = text.encode_utf16().map(|unit| format!("{unit:04X}")).collect::(); assert!(contains(&pdf, &format!("/ActualText ")), "missing text {text}"); } - let lines = wrap_lines("العَرَبِيَّةالعَرَبِيَّة", Font::Helvetica, 20.0, 40.0); - assert_eq!(lines.concat(), "العَرَبِيَّةالعَرَبِيَّة"); - assert!(lines.iter().all(|line| !line.starts_with(['َ', 'ِ', 'ُ', 'ّ', 'ْ']))); - assert!(lines.iter().all(|line| gridcraft_pdf::text_width(Font::Helvetica, 20.0, line) <= 40.0)); + let lines = wrap_lines("العَرَبِيَّةالعَرَبِيَّة", Font::Helvetica, 20.0, 40.0, TextDirection::Auto); + assert_eq!(lines.iter().map(|line| line.text.as_str()).collect::(), "العَرَبِيَّةالعَرَبِيَّة"); + assert!(lines.iter().all(|line| !line.text.starts_with(['َ', 'ِ', 'ُ', 'ّ', 'ْ']))); + assert!(lines.iter().all(|line| gridcraft_pdf::text_width_with_direction(Font::Helvetica, 20.0, &line.text, line.direction) <= 40.0)); let path = std::env::temp_dir().join(format!("gridcraft-arabic-export-{}.pdf", std::process::id())); std::fs::write(&path, &pdf).unwrap(); eprintln!("Arabic PDF artifact: {}", path.display()); } + #[test] + fn arabic_reading_order_export_artifact() { + if gridcraft_pdf::text_width(Font::Helvetica, 10.0, "مرحبا") == 0.0 && std::env::var_os("CRAFT_FONTS_REQUIRED").is_none() { + return; + } + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Context", "LeftToRight", "RightToLeft"], ["مرحبا ABC !", "مرحبا ABC !", "مرحبا ABC !"], ["123 مرحبا ABC", "123 مرحبا ABC", "123 مرحبا ABC"], ["(مرحبا) ABC", "(مرحبا) ABC", "(مرحبا) ABC"], ["مرحبا ABC ! DEF ? مرحبا", "مرحبا ABC ! DEF ? مرحبا", "مرحبا ABC ! DEF ? مرحبا"]]})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:C", "chars": 24})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:5", "height": 60})).unwrap(); + s.execute("home.fontSize", json!({"range": "A2:C5", "size": 18})).unwrap(); + for (range, order) in [("A2:A5", "Context"), ("B2:B5", "LeftToRight"), ("C2:C5", "RightToLeft")] { + s.execute("home.formatCells", json!({"range": range, "style": {"align": {"reading_order": order}}})).unwrap(); + } + s.execute("home.wrapText", json!({"range": "A5:C5"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"gridlines": true, "header": "&CArabic cell reading order"})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + for text in ["مرحبا ABC !", "123 مرحبا ABC", "(مرحبا) ABC"] { + let utf16 = text.encode_utf16().map(|unit| format!("{unit:04X}")).collect::(); + assert!(contains(&pdf, &format!("/ActualText "))); + } + let path = std::env::temp_dir().join(format!("gridcraft-arabic-reading-order-{}.pdf", std::process::id())); + std::fs::write(&path, &pdf).unwrap(); + eprintln!("Arabic reading-order PDF artifact: {}", path.display()); + } + + #[test] + fn wrapped_lines_keep_the_paragraph_direction() { + let lines = wrap_lines("مرحبا ABC ! DEF ?\nLatin مرحبا !", Font::Helvetica, 10.0, 30.0, TextDirection::Auto); + let split = lines.iter().position(|l| l.text.starts_with("Latin")).unwrap(); + assert!(split > 0); + assert!(lines[..split].iter().all(|l| l.direction == TextDirection::RightToLeft)); + assert!(lines[split..].iter().all(|l| l.direction == TextDirection::LeftToRight)); + let lines = wrap_lines("123 مرحبا ABC !", Font::Helvetica, 10.0, 30.0, TextDirection::Context.resolve("123 مرحبا ABC !")); + assert!(lines.iter().all(|l| l.direction == TextDirection::LeftToRight)); + } + + fn exported_text_x(pdf: &[u8], text: &str) -> f32 { + let pdf = String::from_utf8_lossy(pdf); + let marker = format!("({text}) Tj"); + let before = &pdf[..pdf.find(&marker).unwrap()]; + let matrix = before.lines().rev().find(|line| line.ends_with(" Tm")).unwrap(); + matrix.split_whitespace().nth(4).unwrap().parse().unwrap() + } + + #[test] + fn cell_reading_order_and_conditional_context_reach_pdf() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "ABC !"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": "RightToLeft"}}})).unwrap(); + let (rtl, _) = pdf_of(&mut s, json!({})); + assert!(contains(&rtl, "(! ABC) Tj")); + assert!(!contains(&rtl, "/Subtype /Type3")); + s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "noBlanks", "style": {"align": {"reading_order": "Context"}}}})) + .unwrap(); + let (context, _) = pdf_of(&mut s, json!({})); + assert!(contains(&context, "(ABC !) Tj")); + assert!(!contains(&context, "(! ABC) Tj")); + assert!(exported_text_x(&rtl, "! ABC") > exported_text_x(&context, "ABC !")); + } + + #[test] + fn rtl_sheet_sparkline_reflects_with_cell_geometry() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), Value::Number(1.0)); + sh.set_value(CellRef::new(1, 0), Value::Number(3.0)); + sh.sparklines.push(Sparkline { + cell: CellRef::new(0, 1), + source: "A1:A2".into(), + kind: SparklineKind::Line, + color: Color::rgb(255, 0, 0), + markers: false, + }); + let options = PrintOptions { range: RangeRef::parse("A1:B2"), ..PrintOptions::default() }; + let ltr = render_pdf(&wb, &layouts(&wb, &options), "", "").unwrap().0; + wb.sheet_mut(0).unwrap().right_to_left = true; + let rtl = render_pdf(&wb, &layouts(&wb, &options), "", "").unwrap().0; + let points = |pdf: &[u8]| { + let pdf = String::from_utf8_lossy(pdf); + let after = pdf.split("1 0 0 RG\n").nth(1).unwrap(); + let line = after.lines().nth(1).unwrap(); + let values: Vec<_> = line.split_whitespace().collect(); + (values[0].parse::().unwrap(), values[3].parse::().unwrap()) + }; + let (left0, left1) = points(<r); + let (right0, right1) = points(&rtl); + assert!(left0 < left1 && right0 > right1); + assert!((left0 + right0 - 612.0).abs() < 0.001); + assert!((left1 + right1 - 612.0).abs() < 0.001); + } + + #[test] + fn rtl_sheet_pdf_mirrors_cells_merges_headings_and_object_anchors() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["FIRST", "SECOND"], [null, "MERGED"]]})).unwrap(); + s.execute("home.mergeCenter", json!({"range": "B2:C2"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"printArea": "A1:D5", "headings": true, "gridlines": true, "left": 0.8, "right": 0.4})).unwrap(); + s.execute("insert.shape", json!({"kind": "rectangle", "at": "B4", "width": 128, "height": 30, "text": "OBJECT"})).unwrap(); + let (ltr, _) = pdf_of(&mut s, json!({})); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + let (rtl, _) = pdf_of(&mut s, json!({})); + for label in ["FIRST", "SECOND", "MERGED", "A", "B", "C", "D", "1", "2", "OBJECT"] { + let left = exported_text_x(<r, label); + let right = exported_text_x(&rtl, label); + let font_size = if ["A", "B", "C", "D", "1", "2"].contains(&label) { 9.0 } else { 11.0 }; + let width = gridcraft_pdf::text_width(Font::Helvetica, font_size, label); + assert!((left + right + width - 612.0).abs() < 0.01, "{label}: {left}, {right}, {width}"); + } + assert!(exported_text_x(<r, "FIRST") < exported_text_x(<r, "SECOND")); + assert!(exported_text_x(&rtl, "FIRST") > exported_text_x(&rtl, "SECOND")); + for (name, pdf) in [("ltr", ltr), ("rtl", rtl)] { + let path = std::env::temp_dir().join(format!("gridcraft-print-direction-{name}-{}.pdf", std::process::id())); + std::fs::write(&path, pdf).unwrap(); + eprintln!("Print direction PDF artifact: {}", path.display()); + } + } + #[test] fn header_sections_split_join_and_field_names() { let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; @@ -2473,11 +2686,23 @@ mod tests { let b = bands(0, 2, |r| if r == 1 { 100.0 } else { 10.0 }, |_| 35.0, &[], 100); assert_eq!(b, vec![(0, 0), (1, 1), (2, 2)]); assert_eq!(bands(0, 1_000_000, |_| 10.0, |_| 5.0, &[], 3).len(), 3); - let w = wrap_lines("the quick brown fox jumps", Font::Helvetica, 10.0, 50.0); + let w = wrap_lines("the quick brown fox jumps", Font::Helvetica, 10.0, 50.0, TextDirection::Auto); assert!(w.len() >= 2); - assert!(w.iter().all(|l| gridcraft_pdf::text_width(Font::Helvetica, 10.0, l) <= 50.0 || !l.contains(' '))); - let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); + assert!( + w.iter().all(|l| gridcraft_pdf::text_width_with_direction(Font::Helvetica, 10.0, &l.text, l.direction) <= 50.0 || !l.text.contains(' ')) + ); + let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0, TextDirection::Auto); assert!(w.len() > 2); + let long = "abcdefghij".repeat(300); + let w = wrap_lines(&long, Font::Helvetica, 10.0, 50.0, TextDirection::Auto); + assert_eq!(w.iter().map(|l| l.text.as_str()).collect::(), long); + assert!(w.iter().all(|l| gridcraft_pdf::text_width_with_direction(Font::Helvetica, 10.0, &l.text, l.direction) <= 50.0)); + let mut at = 0; + for l in &w[..w.len() - 1] { + let one_more = &long[at..at + l.text.len() + 1]; + assert!(gridcraft_pdf::text_width_with_direction(Font::Helvetica, 10.0, one_more, l.direction) > 50.0, "each split is as long as fits"); + at += l.text.len(); + } } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 46cc1f5f49..a23e935cd7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -18,6 +18,44 @@ fn v(s: &Session, a: &str) -> Value { s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) } +#[test] +fn format_cells_reading_order_survives_undo_and_xlsx() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap(); + s.execute("home.bold", json!({"range": "A1"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": "RightToLeft"}}})).unwrap(); + let alignment = |s: &Session| { + let d = s.doc().unwrap(); + let style = d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::new(0, 0))); + assert!(style.font.bold); + serde_json::to_value(style.align).unwrap() + }; + assert_eq!(alignment(&s)["reading_order"], "RightToLeft"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(alignment(&s)["reading_order"].is_null()); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(alignment(&s)["reading_order"], "RightToLeft"); + for order in [json!("invalid"), json!(2)] { + assert!(s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": order}}})).is_err()); + assert_eq!(alignment(&s)["reading_order"], "RightToLeft"); + } + let wb = &s.doc().unwrap().wb; + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap(); + let cell = CellRef::new(0, 0); + assert_eq!(reopened.styles.get(reopened.active().unwrap().style_id(cell)), wb.styles.get(wb.active().unwrap().style_id(cell))); + assert_eq!(reopened.active().unwrap().value(cell), Value::text("مرحبا 123 ABC")); + for order in ["LeftToRight", "Context"] { + s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": order}}})).unwrap(); + assert_eq!(alignment(&s)["reading_order"], order); + } + s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": null}}})).unwrap(); + assert!(alignment(&s)["reading_order"].is_null()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(alignment(&s)["reading_order"], "Context"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(alignment(&s)["reading_order"].is_null()); +} + #[test] fn entering_empty_cells_preserves_whole_sheet_formatting() { let mut s = s(); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 071de683c2..7c8e326159 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -22,8 +22,8 @@ pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, Pres pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ - Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, - VAlign, VertAlign, + Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, ReadingOrder, Style, StyleId, StyleTable, Theme, + Underline, VAlign, VertAlign, }; pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook}; diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 1ded992885..46a4394e6e 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -350,6 +350,14 @@ pub enum VAlign { Distributed, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ReadingOrder { + #[default] + Context, + LeftToRight, + RightToLeft, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Alignment { pub h: HAlign, @@ -359,6 +367,9 @@ pub struct Alignment { pub indent: u8, /// Degrees -90..=90, or 255 for vertical stacked text. pub rotation: i16, + /// Unspecified inherits in differential styles and uses context for ordinary cells. + #[serde(default)] + pub reading_order: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -488,6 +499,25 @@ static DEFAULT_STYLE: std::sync::LazyLock