diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7dd388ccd7..d63b32f16c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -47,5 +47,7 @@ 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 | -| Core 14 AFM metric numbers in `crates/pdf/src/symbol.rs` (Symbol: character codes and advance widths) and `crates/pdf/src/metrics.rs` (Helvetica, Times and Courier advance widths); no font file | Adobe Systems Incorporated | Adobe's Core 14 AFM font metrics files (`Symbol.afm` 001.008 and the Helvetica, Times and Courier AFMs) | Adobe's Core 14 metrics notice: the files may be used, copied and distributed for any purpose and without charge, provided the notice is kept (quoted in `crates/pdf/src/metrics.rs`) | Numbers only, typed in from the public metrics. PDF export embeds no font: Symbol and the Times faces are base-14 fonts every PDF reader supplies, and text outside WinAnsiEncoding is set in a predefined, non-embedded Adobe-GB1 CID font | +| Core 14 AFM metric numbers in `crates/pdf/src/symbol.rs` (Symbol: character codes and advance widths) and `crates/pdf/src/metrics.rs` (Helvetica, Times and Courier advance widths); no font file | Adobe Systems Incorporated | Adobe's Core 14 AFM font metrics files (`Symbol.afm` 001.008 and the Helvetica, Times and Courier AFMs) | Adobe's Core 14 metrics notice: the files may be used, copied and distributed for any purpose and without charge, provided the notice is kept (quoted in `crates/pdf/src/metrics.rs`) | Numbers only, typed in from the public metrics. PDF export embeds none of these fonts: Symbol and the Times faces are base-14 fonts every PDF reader supplies, and non-Arabic text outside WinAnsiEncoding is set in a predefined, non-embedded Adobe-GB1 CID font | | Unicode values of the Symbol encoding in `crates/pdf/src/symbol.rs` (the code-to-character map; numbers only, no file copied) | Unicode, Inc. | `symbol.txt`, "Adobe Symbol Encoding to Unicode", table version 1.0 (2011-07-12), at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/ | Unicode, Inc.'s notice in the file: "Unicode, Inc. hereby grants the right to freely use the information supplied in this file in the creation of products supporting the Unicode Standard, and to make copies of this file in any form for internal or external distribution as long as this notice remains attached." The file is provided as-is, without warranties | Separate from Adobe's Core 14 notice (previous row), which covers the character codes and widths; the Unicode values come from this table | + +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 395518b46c..53fead9949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2219,6 +2219,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", "web-time", "zip 3.0.0", ] @@ -2282,6 +2283,12 @@ dependencies = [ [[package]] name = "gridcraft-pdf" version = "0.19.1" +dependencies = [ + "harfrust", + "sha2 0.11.1", + "skrifa", + "unicode-bidi", +] [[package]] name = "gridcraft-sandbox" diff --git a/ROADMAP.md b/ROADMAP.md index 5ff15b4221..85e83feb9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) | | 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used | | 2026-10-31 | System and embedded CJK fallback text shares the Latin baseline in cells, editors and window titles (#1265, #2621). Symbol and emoji alignment, manual row heights and clipping are unchanged | +| 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; characters the font lacks, or a missing Arabic font, fall back to the standard fonts instead of failing the export | | 2026-10-26 | Arabic bidi display/editing preserve logical source text; per-cell reading direction is editable and survives undo, XLSX and conditional-format priority | | 2026-10-26 | Arabic text shows in cells and the interface through a system, fontconfig or craft-fonts Arabic face; workbook fonts keep their script fallbacks | | 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 | @@ -285,6 +287,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories | | 2026-10-31 | minor | Progress log: Function Library | | 2026-10-31 | minor | Progress log: shared CJK/Latin baselines in cells, editors and window titles | +| 2026-10-26 | minor | Progress log: PDF reading direction and RTL sheets | +| 2026-10-26 | minor | Progress log: Arabic PDF text | | 2026-10-26 | minor | Progress log: Arabic bidi display and editing | | 2026-10-26 | minor | Progress log: Arabic font fallback | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9481b0476d..c68fb117a4 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -26,6 +26,7 @@ gridcraft-chart = { workspace = true } gridcraft-pdf = { workspace = true } gridcraft-math = { 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 875ebdc2b0..0f6ba272c6 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -13,13 +13,15 @@ use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; #[cfg(test)] use gridcraft_model::{Alignment, Image, SheetViewMode}; use gridcraft_model::{ - Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PageOrder, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, - StyleTable, Underline, VAlign, Visibility, Workbook, + Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PageOrder, PatternType, PrintSettings, ReadingOrder, ShapeKind, Sheet, + Style, StyleId, StyleTable, Underline, VAlign, Visibility, Workbook, }; -use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb}; +use gridcraft_pdf::{Baseline, Font, Page, PdfDoc, Point, Rect, Rgb, TextDirection}; use serde_json::{Value as Json, json}; -use super::{CommandSpec, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param, target_sheet, u32_param}; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param, target_sheet, u32_param, +}; mod draw; mod header_footer; @@ -151,8 +153,17 @@ pub(crate) const PX_TO_PT: f32 = 0.75; /// Most pages one export produces. pub const MAX_PAGES: usize = 2000; +/// A rendered PDF and whether some of its Arabic text was set without an Arabic face. +pub struct RenderedPdf { + pub bytes: Vec, + pub pages: usize, + pub missing_arabic_font: bool, +} + +const MISSING_ARABIC_FONT: &str = "Some Arabic text could not be drawn in this PDF because no Noto Sans Arabic face was found for it."; + /// 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 { 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(); @@ -188,7 +199,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)); } @@ -204,6 +224,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]); } @@ -227,18 +248,24 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str page.link_page(Rect::new(lx, ly, w, h), target_page, Point::new(x, y)); } } - let n = doc.page_count(); - (doc.finish(), n) + let pages = doc.page_count(); + let missing_arabic_font = doc.missing_arabic_font(); + let bytes = doc.finish().map_err(|error| EngineError::Other(error.to_string()))?; + Ok(RenderedPdf { bytes, pages, missing_arabic_font }) } // ---------------------------------------------------------------- commands -fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec, usize)> { +fn build_pdf(s: &mut Session, p: &Json, cmd: &str) -> Result<(Vec, usize)> { let opts = PrintOptions::from_params(p, cmd)?; 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)) + let pdf = render_pdf(&d.wb, &lays, &d.display_title(), &path)?; + if pdf.missing_arabic_font { + s.ui_requests.push(crate::UiRequest::Message(MISSING_ARABIC_FONT.into())); + } + Ok((pdf.bytes, pdf.pages)) } pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/print/draw.rs b/crates/engine/src/cmd/print/draw.rs index 595b70d862..35fd41bd41 100644 --- a/crates/engine/src/cmd/print/draw.rs +++ b/crates/engine/src/cmd/print/draw.rs @@ -32,6 +32,9 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option { pub(super) ys: Vec, pub(super) k: f32, pub(super) scale: f32, + pub(super) mirror_axis: f32, } impl Block<'_> { + fn point_x(&self, x: f32) -> f32 { + if self.sh.right_to_left { self.mirror_axis - x } else { x } + } + pub(super) fn text_x(&self, x: f32, width: f32) -> f32 { + if self.sh.right_to_left { self.mirror_axis - x - width } else { x } + } + pub(super) 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); @@ -328,15 +342,15 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt set }; pg.save(); - pg.clip_rect(bx, by, bw, bh); + pg.clip_rect(b.text_x(bx, bw), by, bw, bh); if gridlines { let grey = [0xB0, 0xB0, 0xB0]; let lw = (0.35 * b.scale).clamp(0.1, 0.5); for x in &b.xs { - pg.stroke_line(*x, by, *x, by + bh, lw, grey); + pg.stroke_line(b.point_x(*x), by, b.point_x(*x), by + bh, lw, grey); } for y in &b.ys { - pg.stroke_line(bx, *y, bx + bw, *y, lw, grey); + pg.stroke_line(b.point_x(bx), *y, b.point_x(bx + bw), *y, lw, grey); } } // Cheap pre-filter: blank, unstyled cells outside tables and conditional formats draw nothing. @@ -379,17 +393,17 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt continue; } if let Some(f) = l.fill { - pg.fill_rect(x, y, w, h, f); + pg.fill_rect(b.text_x(x, w), y, w, h, f); } else if merged && gridlines { - pg.fill_rect(x, y, w, h, [255, 255, 255]); + pg.fill_rect(b.text_x(x, w), y, w, h, [255, 255, 255]); } if merged && gridlines { - pg.rect_stroke(x, y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]); + pg.rect_stroke(b.text_x(x, w), y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]); } if let Some((frac, rgb, _)) = l.cf.as_ref().and_then(|cf| cf.bar) { let pad = 1.5 * b.k; let bwid = ((w - 2.0 * pad) * frac.clamp(0.0, 1.0)).max(0.0); - pg.fill_rect(x + pad, y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb); + pg.fill_rect(b.text_x(x + pad, bwid), y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb); } } // Borders. @@ -406,12 +420,12 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt ), None => (bd.right, bd.bottom), }; - draw_border(pg, &bd.top, b.wb, Point::new(x, y), Point::new(x + w, y), b.k); - draw_border(pg, &bottom, b.wb, Point::new(x, y + h), Point::new(x + w, y + h), b.k); - draw_border(pg, &bd.left, b.wb, Point::new(x, y), Point::new(x, y + h), b.k); - draw_border(pg, &right, b.wb, Point::new(x + w, y), Point::new(x + w, y + h), b.k); - draw_border(pg, &bd.diag_down, b.wb, Point::new(x, y), Point::new(x + w, y + h), b.k); - draw_border(pg, &bd.diag_up, b.wb, Point::new(x, y + h), Point::new(x + w, y), b.k); + draw_border(pg, &bd.top, b.wb, Point::new(b.point_x(x), y), Point::new(b.point_x(x + w), y), b.k); + draw_border(pg, &bottom, b.wb, Point::new(b.point_x(x), y + h), Point::new(b.point_x(x + w), y + h), b.k); + draw_border(pg, &bd.left, b.wb, Point::new(b.point_x(x), y), Point::new(b.point_x(x), y + h), b.k); + draw_border(pg, &right, b.wb, Point::new(b.point_x(x + w), y), Point::new(b.point_x(x + w), y + h), b.k); + draw_border(pg, &bd.diag_down, b.wb, Point::new(b.point_x(x), y), Point::new(b.point_x(x + w), y + h), b.k); + draw_border(pg, &bd.diag_up, b.wb, Point::new(b.point_x(x), y + h), Point::new(b.point_x(x + w), y), b.k); } // Text. for (c, l) in &looks { @@ -420,9 +434,9 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt continue; } if let Some(picture) = sh.cell_pictures.get(c) { - draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k); + draw_cell_picture(pg, &picture.data, (b.text_x(x, w), y, w, h), b.k); } else if let Some(on) = checkbox_state(l, &sh.value(*c)) { - draw_checkbox(pg, (x, y, w, h), b.k, on); + draw_checkbox(pg, (b.text_x(x, w), y, w, h), b.k, on); } else { draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) }); } @@ -476,6 +490,8 @@ pub(super) struct PrintedBlock { pub(super) x: f32, pub(super) y: f32, pub(super) k: f32, + /// The page x that right-to-left sheets are mirrored about; `None` for left-to-right sheets. + pub(super) mirror_axis: Option, } /// PDF annotations are not affected by graphics clipping, so intersect each cell with its page @@ -495,7 +511,7 @@ pub(super) fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: & if x1 <= x0 || y1 <= y0 { continue; } - let rect = (x0, y0, x1 - x0, y1 - y0); + let rect = (b.text_x(x0, x1 - x0), y0, x1 - x0, y1 - y0); match crate::links::classify(&link.target) { crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri), crate::links::LinkKind::Internal(target) if !link.external => { @@ -514,6 +530,10 @@ pub(super) fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_shee 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)) } @@ -530,11 +550,11 @@ pub(super) fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads 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 { @@ -544,11 +564,11 @@ pub(super) fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads 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); } } } @@ -579,7 +599,7 @@ pub(super) fn make_block<'a>( 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 } } #[cfg(test)] diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs index 55d9f8ac81..5b786b7019 100644 --- a/crates/engine/src/cmd/print/objects.rs +++ b/crates/engine/src/cmd/print/objects.rs @@ -1,6 +1,6 @@ use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of}; use super::text::wrap_lines; -use super::{Anchor, Font, Page, RangeRef, Rect, ShapeKind, Sheet, Value, Workbook}; +use super::{Anchor, Font, Page, Point, RangeRef, Rect, ShapeKind, Sheet, TextDirection, Value, Workbook}; /// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio. pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) { @@ -76,9 +76,11 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, 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); @@ -153,9 +155,19 @@ 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); + 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 }); } } @@ -199,7 +211,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, @@ -207,7 +219,7 @@ 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, @@ -216,7 +228,7 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl // WordArt's drop shadow: the line again, offset, in the shadow colour (under the text). if let Some(sc) = fm.text_shadow.and_then(|c| rgb_of(c, wb)) { let off = (size * 0.06).max(0.5); - pg.text(lx + off, yy + off, size, font, sc, l); + pg.text_with_direction(Point::new(lx + off, yy + off), size, font, sc, &l.text, l.direction); } // WordArt's text outline, as on screen: the line in the outline colour, offset in eight // directions by half the outline width, under the filled text. @@ -225,10 +237,10 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl { let d = (ow * b.scale / 2.0).clamp(0.25, 20.0); for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] { - pg.text(lx + dx * d, yy + dy * d, size, font, oc, l); + pg.text_with_direction(Point::new(lx + dx * d, yy + dy * d), size, font, oc, &l.text, l.direction); } } - pg.text(lx, yy, size, font, color, l); + pg.text_with_direction(Point::new(lx, yy), size, font, color, &l.text, l.direction); yy += size * 1.2; } } diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 5ebd733018..884432317b 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -1,3 +1,4 @@ +use gridcraft_model::{Sparkline, SparklineKind}; use serde_json::json; use super::*; @@ -15,6 +16,35 @@ fn pdf_of(s: &mut Session, p: Json) -> (Vec, u64) { fn contains(hay: &[u8], needle: &str) -> bool { hay.windows(needle.len()).any(|w| w == needle.as_bytes()) } +fn arabic_face_available() -> bool { + let mut doc = gridcraft_pdf::PdfDoc::new(); + doc.add_page(100.0, 100.0).text(0.0, 50.0, 10.0, Font::Helvetica, [0, 0, 0], "مرحبا"); + !doc.missing_arabic_font() +} + +#[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 chart_data_table_prints_formatted_values_and_follows_recalculation() { @@ -58,6 +88,199 @@ fn pdf_export_keeps_safe_links_on_their_printed_pages() { assert!(!cropped.contains("/Dest [")); } +#[test] +fn arabic_with_characters_its_font_lacks_still_exports() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 😀"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "مرحبا Привет"})).unwrap(); + let out = s.execute("file.exportPdf", json!({})).unwrap(); + assert_eq!(out["pages"], 1); +} + +#[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, 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] +fn arabic_cells_headers_and_charts_export_searchable_shaped_text() { + if !arabic_face_available() && std::env::var_os("CRAFT_FONTS_REQUIRED").is_none() { + return; + } + 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(); + { + 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, 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 !arabic_face_available() && 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().bytes; + wb.sheet_mut(0).unwrap().right_to_left = true; + let rtl = render_pdf(&wb, &layouts(&wb, &options), "", "").unwrap().bytes; + 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().find(|line| line.ends_with(" l S")).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 explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["LEFT"], ["RIGHT"]]})).unwrap(); + s.execute("home.alignLeft", json!({"range": "A1"})).unwrap(); + s.execute("home.alignRight", json!({"range": "A2"})).unwrap(); + let (ltr, _) = pdf_of(&mut s, json!({})); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + let (rtl, _) = pdf_of(&mut s, json!({})); + let width = |label| gridcraft_pdf::text_width(Font::Helvetica, 11.0, label); + let (left, right) = (width("LEFT"), width("RIGHT")); + let ltr_gap = exported_text_x(<r, "RIGHT") + right - exported_text_x(<r, "LEFT"); + let rtl_gap = exported_text_x(&rtl, "RIGHT") + right - exported_text_x(&rtl, "LEFT"); + assert!((ltr_gap - rtl_gap).abs() < 0.01, "Left stays left of Right in the cell: {ltr_gap}, {rtl_gap}"); + assert!(exported_text_x(&rtl, "LEFT") + left < exported_text_x(&rtl, "RIGHT") + right); +} + +#[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"; @@ -694,11 +917,32 @@ fn bands_unit() { 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(); + } +} + +fn wrapped_text(text: &str, font: Font, size: f32, width: f32) -> Vec { + wrap_lines(text, font, size, width, TextDirection::Auto).into_iter().map(|line| line.text).collect() +} + +#[test] +fn wrapping_ignores_the_width_of_undrawn_bidi_marks() { + let word = "i\u{200e}".repeat(20); + assert!(gridcraft_pdf::text_width(Font::Helvetica, 10.0, &word) <= 50.0); + assert_eq!(wrapped_text(&word, Font::Helvetica, 10.0, 50.0), vec![word.clone()]); } /// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines. @@ -755,7 +999,11 @@ fn wrap_lines_matches_the_remeasuring_wrapper() { for text in texts { for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] { for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] { - assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}"); + assert_eq!( + wrapped_text(text, font, size, width), + wrap_lines_remeasuring(text, font, size, width), + "{text:?} {font:?} {size} {width}" + ); } } } @@ -766,12 +1014,24 @@ fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() { // One character per line: the old wrapper re-measured and copied the rest of the word for // each of the 100,000 lines before checking the limit. let word = "w".repeat(100_000); - let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0); + let lines = wrapped_text(&word, Font::Helvetica, 10.0, 1.0); assert!(lines.len() <= 2002, "{}", lines.len()); assert!(lines.iter().all(|l| l == "w")); // A long paragraph in a very wide box is one line. let para = vec!["word"; 50_000].join(" "); - assert_eq!(wrap_lines(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]); + assert_eq!(wrapped_text(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]); +} + +#[test] +fn wrapping_a_long_arabic_word_measures_pieces_not_the_rest_of_the_word() { + let word = "مرحبا".repeat(10_000); + if !arabic_face_available() { + return; + } + let lines = wrapped_text(&word, Font::Helvetica, 10.0, 20.0); + assert!(lines.len() > 2000 && lines.len() <= 2002, "{}", lines.len()); + assert!(lines.iter().take(2000).all(|line| gridcraft_pdf::text_width(Font::Helvetica, 10.0, line) <= 20.0)); + assert!(word.starts_with(&lines.concat())); } #[test] diff --git a/crates/engine/src/cmd/print/text.rs b/crates/engine/src/cmd/print/text.rs index 0523aa4ffc..3c4c8374d1 100644 --- a/crates/engine/src/cmd/print/text.rs +++ b/crates/engine/src/cmd/print/text.rs @@ -1,78 +1,176 @@ use super::draw::{Block, Look, rgb_of}; -use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet, Style, StyleTable, Underline, VAlign, Value}; +use super::{ + Baseline, CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, ReadingOrder, Sheet, Style, StyleTable, TextDirection, Underline, + VAlign, Value, +}; +use unicode_segmentation::UnicodeSegmentation; const PAD_PX: f32 = 2.0; /// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph). const MAX_WRAP_LINES: usize = 2000; -/// Word-wraps `text` to `width` points. +pub(super) struct TextLine { + pub(super) text: String, + pub(super) direction: TextDirection, +} + +/// Word-wraps `text` to `width` points, keeping each paragraph's base direction on every +/// continuation line. /// /// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each /// character is measured once: re-measuring the line for every word, or the rest of an /// over-long word for every piece cut from it, made a long paragraph or word quadratic. -pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { +/// A paragraph whose width is not that sum is measured as drawn by [`wrap_measured_paragraph`]. +pub(super) 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); + if gridcraft_pdf::width_is_char_sum(font, para, direction) { + wrap_summed_paragraph(para, font, size, width, direction, &mut out); + } else { + let fits = |s: &str| gridcraft_pdf::text_width_with_direction(font, size, s, direction) <= width; + wrap_measured_paragraph(para, &fits, direction, &mut out); + } + if out.len() > MAX_WRAP_LINES { + return out; + } + } + out +} + +fn wrap_summed_paragraph(para: &str, font: Font, size: f32, width: f32, direction: TextDirection, out: &mut Vec) { let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() }; let fits = |units: u64| units as f32 * size / 1000.0 <= width; let space = units_of(" "); - let mut out = Vec::new(); - for para in text.split('\n') { - let mut line = String::new(); - let mut line_units: u64 = 0; - for word in para.split(' ') { - let word_units = units_of(word); - if line.is_empty() { - line.push_str(word); - line_units = word_units; - } else if fits(line_units + space + word_units) { - line.push(' '); - line.push_str(word); - line_units += space + word_units; - } else { - out.push(std::mem::take(&mut line)); - line.push_str(word); - line_units = word_units; - } - // Break a single over-long word by characters: each piece is the longest that fits - // (at least one character). - if !fits(line_units) && line.chars().nth(1).is_some() { - let mut piece = String::new(); - let mut units: u64 = 0; - for c in line.chars() { - let w = u64::from(font.char_width(c)); - if !piece.is_empty() && !fits(units + w) { - out.push(std::mem::take(&mut piece)); - units = 0; - if out.len() > MAX_WRAP_LINES { - return out; - } + let mut line = String::new(); + let mut line_units: u64 = 0; + for word in para.split(' ') { + let word_units = units_of(word); + if line.is_empty() { + line.push_str(word); + line_units = word_units; + } else if fits(line_units + space + word_units) { + line.push(' '); + line.push_str(word); + line_units += space + word_units; + } else { + out.push(TextLine { text: std::mem::take(&mut line), direction }); + line.push_str(word); + line_units = word_units; + } + if !fits(line_units) && line.graphemes(true).nth(1).is_some() { + let mut piece = String::new(); + let mut units: u64 = 0; + for g in line.graphemes(true) { + let w = units_of(g); + if !piece.is_empty() && !fits(units + w) { + out.push(TextLine { text: std::mem::take(&mut piece), direction }); + units = 0; + if out.len() > MAX_WRAP_LINES { + return; } - piece.push(c); - units += w; } - line = piece; - line_units = units; + piece.push_str(g); + units += w; + } + line = piece; + line_units = units; + } + if out.len() > MAX_WRAP_LINES { + return; + } + } + out.push(TextLine { text: line, direction }); +} + +/// One paragraph of [`wrap_lines`] whose width is not a sum of character widths (shaped Arabic, +/// right-to-left reordering or bidi controls), measuring each candidate line as drawn. +fn wrap_measured_paragraph(para: &str, fits: &impl Fn(&str) -> bool, direction: TextDirection, out: &mut Vec) { + let mut line = String::new(); + for word in para.split(' ') { + if !line.is_empty() { + let candidate = format!("{line} {word}"); + if fits(&candidate) { + line = candidate; + continue; + } + out.push(TextLine { text: std::mem::take(&mut line), direction }); + } + line = if fits(word) { word.to_string() } else { break_measured_word(word, fits, direction, out) }; + if out.len() > MAX_WRAP_LINES { + return; + } + } + out.push(TextLine { text: line, direction }); +} + +/// Cuts `word` into the longest grapheme prefixes that fit (at least one grapheme each), pushes +/// all but the last to `out` and returns the last. Each cut gallops from one grapheme, so it +/// measures prefixes about as long as the piece rather than the rest of the word. +fn break_measured_word(word: &str, fits: &impl Fn(&str) -> bool, direction: TextDirection, out: &mut Vec) -> String { + let ends: Vec = word.grapheme_indices(true).map(|(i, g)| i + g.len()).collect(); + let (mut start, mut first) = (0, 0); + loop { + let len = ends.len().saturating_sub(first); + let end = |count: usize| ends.get(first + count.saturating_sub(1)).copied().unwrap_or(word.len()); + let fits_prefix = |count: usize| fits(word.get(start..end(count)).unwrap_or_default()); + let (mut fit, mut over, mut probe) = (1, len + 1, 2); + while fit < len { + let count = probe.min(len); + if fits_prefix(count) { + fit = count; + probe = count.saturating_mul(2); + } else { + over = count; + break; } - if out.len() > MAX_WRAP_LINES { - return out; + } + if fit >= len { + return word.get(start..).unwrap_or_default().to_string(); + } + while over - fit > 1 { + let mid = fit + (over - fit) / 2; + if fits_prefix(mid) { + fit = mid; + } else { + over = mid; } } - out.push(line); + let cut = end(fit); + out.push(TextLine { text: word.get(start..cut).unwrap_or_default().to_string(), direction }); + start = cut; + first += fit; + if out.len() > MAX_WRAP_LINES { + return word.get(start..).unwrap_or_default().to_string(); + } } - out } fn font_for(style: &Style) -> Font { Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic) } -fn text_align(align: HAlign, value: &Value) -> HAlign { +fn text_direction(order: ReadingOrder) -> TextDirection { + match order { + ReadingOrder::Context => TextDirection::Context, + ReadingOrder::LeftToRight => TextDirection::LeftToRight, + ReadingOrder::RightToLeft => TextDirection::RightToLeft, + } +} + +/// The alignment a cell is laid out with before a right-to-left sheet is mirrored. Explicit Left +/// and Right are swapped there, as in the grid, so they print on the side the user chose. +fn text_align(align: HAlign, value: &Value, direction: TextDirection, right_to_left_sheet: bool) -> HAlign { match align { HAlign::General => match value { Value::Number(_) => HAlign::Right, Value::Bool(_) | Value::Error(_) => HAlign::Center, + _ if direction == TextDirection::RightToLeft && !right_to_left_sheet => HAlign::Right, _ => HAlign::Left, }, + HAlign::Left if right_to_left_sheet => HAlign::Right, + HAlign::Right if right_to_left_sheet => HAlign::Left, HAlign::CenterAcross | HAlign::Distributed => HAlign::Center, HAlign::Justify | HAlign::Fill => HAlign::Left, other => other, @@ -183,32 +281,46 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) if text.is_empty() { return; } - let mut tw = gridcraft_pdf::text_width(font, size, &text); + let direction = text_direction(st.align.reading_order.unwrap_or_default()).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 = text_align(st.align.h, &v); + let h_align = text_align(st.align.h, &v, direction, sh.right_to_left); 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 line_h = size * 1.2; + let extents = lines + .iter() + .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)); 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. @@ -222,7 +334,7 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) 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 @@ -233,23 +345,27 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) 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(Point::new(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(Baseline { origin: Point::new(ox, oy), angle }, size, font, color, &t.text, t.direction); } pg.restore(); return; } 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: 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 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_with_direction(Point::new(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; @@ -259,7 +375,7 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) 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, @@ -269,7 +385,8 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) 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(Point::new(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); diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index cd63456c20..9a32a86207 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -3,10 +3,17 @@ 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" readme = "README.md" [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/README.md b/crates/pdf/README.md index cdb6308eb2..c8ec1c9b30 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -1,6 +1,6 @@ # gridcraft-pdf -A small, dependency-free PDF 1.7 writer for vector drawing and RGBA images. +A small PDF 1.7 writer for shaped Arabic text, vector drawing and RGBA images. ## API migration 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..11a531729f --- /dev/null +++ b/crates/pdf/src/arabic.rs @@ -0,0 +1,669 @@ +//! 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, TextDirection, dict_obj, n, needs_cid, pdf_string, pdf_utf16_string, stream_obj}; + +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; + +/// How `text` set in `font` is drawn. Symbol keeps its own one-run encoding, and Arabic without +/// an installed or bundled face falls back to the standard fonts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Shaping { + Plain, + Shaped, + NoFont, +} + +pub(crate) fn shaping(font: Font, text: &str) -> Shaping { + if font == Font::Symbol || !needed(text) { + return Shaping::Plain; + } + match face(bold(font)) { + Ok(None) => Shaping::NoFont, + Ok(Some(_)) | Err(_) => Shaping::Shaped, + } +} + +pub(crate) fn shaped(font: Font, text: &str) -> bool { + shaping(font, text) == Shaping::Shaped +} + +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.is_some_and(|face| face.synthetic_bold)) +} + +/// The Arabic face for a weight, or `None` when none is bundled or installed. The search runs +/// once per weight, so measuring text never rescans the font directories. +fn face(is_bold: bool) -> Result, Error> { + static REGULAR: OnceLock, Error>> = OnceLock::new(); + static BOLD: OnceLock, Error>> = OnceLock::new(); + let cache = if is_bold { &BOLD } else { ®ULAR }; + cache.get_or_init(|| find_face(ARABIC_FONTS, &system_font_dirs(), is_bold)).as_ref().map(Option::as_ref).map_err(Error::clone) +} + +fn loaded_face(font: Font) -> Result<&'static Face, Error> { + face(bold(font))?.ok_or_else(|| Error("Arabic PDF export needs Noto Sans Arabic".into())) +} + +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) +} + +#[cfg(not(target_arch = "wasm32"))] +fn system_font_dirs() -> Vec { + use std::path::PathBuf; + 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")); + } + dirs +} + +#[cfg(target_arch = "wasm32")] +fn system_font_dirs() -> Vec { + Vec::new() +} + +fn find_face(bundled: &[(&str, &'static [u8])], dirs: &[std::path::PathBuf], is_bold: bool) -> Result, Error> { + use std::io::Read as _; + if let Some(bytes) = bundled_match(bundled, is_bold) { + return Face::new(Cow::Borrowed(bytes), is_bold).map(Some); + } + 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).map(Some); + } + } + if let Some((name, bytes)) = bundled_fallback(bundled, is_bold) { + let mut face = Face::new(Cow::Borrowed(bytes), is_bold)?; + face.synthetic_bold = needs_synthetic_bold(name, bytes, is_bold); + return Ok(Some(face)); + } + Ok(None) +} + +/// 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, direction: TextDirection) -> Result { + if text.len() > MAX_TEXT_BYTES { + return Err(Error("Arabic PDF text exceeds the 128 KiB per-line limit".into())); + } + let direction = if direction == TextDirection::Context { direction.resolve(text) } else { direction }; + let face = loaded_face(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, direction.level()); + 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()))?; + 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 { + 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| needs_cid(*a) == needs_cid(*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| needs_cid(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, direction: TextDirection) -> Result<(f32, f32), Error> { + let layout = layout(font, text, direction)?; + let face = loaded_face(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 { + 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)) +} + +/// Whether standard-font `text` is drawn in its logical order, unchanged. +pub(crate) fn is_logical_order(text: &str, direction: TextDirection) -> bool { + direction != TextDirection::RightToLeft && !text.chars().any(is_control) +} + +/// Bidi reordering for standard-font text requires no optional Arabic font. +pub(crate) fn latin_visual(text: &str, direction: TextDirection) -> Result, Error> { + if is_logical_order(text, direction) { + return Ok(Cow::Borrowed(text)); + } + if text.len() > MAX_TEXT_BYTES { + return Err(Error("Bidirectional PDF text exceeds the 128 KiB per-line limit".into())); + } + 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, direction.level()); + let mut out = String::with_capacity(text.len()); + for para in &bidi.paragraphs { + let (levels, runs) = bidi.visual_runs(para, para.range.clone()); + for run in runs { + let part = text.get(run.clone()).unwrap_or_default(); + if levels.get(run.start).is_some_and(|level| level.is_rtl()) { + out.extend(part.chars().rev().filter(|ch| !is_control(*ch)).map(mirror_ansi)); + } else { + out.extend(part.chars().filter(|ch| !is_control(*ch))); + } + } + } + Ok(Cow::Owned(out)) +} + +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 = loaded_face(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, direction: TextDirection) -> Result { + let layout = layout(font, text, direction)?; + 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) + } + + /// Writes the subset fonts into `out` and returns their page resource entries. + pub(crate) fn write_objects(&self, next: &mut u32, out: &mut Vec, offsets: &mut 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); + stream_obj(out, offsets, *next, "", body.as_bytes()); + *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"); + stream_obj(out, offsets, cmap_id, "", cmap.as_bytes()); + dict_obj( + out, + offsets, + 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() + ), + ); + let _ = write!(resources, "/A{} {id} 0 R ", batch + 1); + } + resources + } +} + +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 no_bundled_or_installed_face_is_not_an_error() { + for bold in [false, true] { + assert!(matches!(super::find_face(&[], &[], bold), Ok(None))); + assert!(matches!(super::find_face(&[], &[std::env::temp_dir().join("gridcraft-no-fonts-here")], bold), Ok(None))); + } + } + + #[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 b94aaf6a70..db22568c6b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,20 +5,64 @@ //! 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 `?`. Symbol uses its own //! built-in encoding ([`symbol_code`]), for Greek letters and mathematical operators. +//! - 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; mod symbol; pub use symbol::{symbol_code, symbol_width}; +/// Paragraph base direction. Context follows spreadsheet reading order: the first +/// non-whitespace character determines direction, including digits and punctuation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextDirection { + #[default] + Auto, + Context, + LeftToRight, + RightToLeft, +} + +impl TextDirection { + /// Resolve once for a paragraph before wrapping it into visual lines. + pub fn resolve(self, text: &str) -> Self { + use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; + let rtl = match self { + Self::Auto => BidiInfo::new(text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()), + Self::Context => text.chars().find(|ch| !ch.is_whitespace()).is_some_and(|ch| matches!(bidi_class(ch), BidiClass::R | BidiClass::AL)), + explicit => return explicit, + }; + if rtl { Self::RightToLeft } else { Self::LeftToRight } + } + + fn level(self) -> Option { + match self { + Self::Auto => None, + Self::LeftToRight | Self::Context => Some(unicode_bidi::Level::ltr()), + Self::RightToLeft => Some(unicode_bidi::Level::rtl()), + } + } +} + +/// 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::collections::HashMap; use std::collections::hash_map::DefaultHasher; use std::fmt::Write as _; @@ -40,6 +84,13 @@ impl Point { } } +/// A text baseline origin and its clockwise rotation in radians. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Baseline { + pub origin: Point, + pub angle: f32, +} + /// A page-space rectangle described by its top-left corner and extent. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Rect { @@ -202,10 +253,29 @@ impl Font { } } +/// Whether [`text_width_with_direction`] is the sum of `text`'s character widths: not shaped, +/// not reordered right to left and holding no bidi controls. A prefix's width is then a running sum. +pub fn width_is_char_sum(font: Font, text: &str, direction: TextDirection) -> bool { + let direction = if direction == TextDirection::Context { direction.resolve(text) } else { direction }; + !arabic::shaped(font, text) && arabic::is_logical_order(text, direction) +} + /// 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 { + text_width_with_direction(font, size, text, TextDirection::Auto) +} + +/// Width measured with the same base direction as drawing. +pub fn text_width_with_direction(font: Font, size: f32, text: &str, direction: TextDirection) -> f32 { + let direction = if direction == TextDirection::Context { direction.resolve(text) } else { direction }; + if arabic::shaped(font, text) { + return arabic::layout(font, text, direction).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(); + let Ok(visual) = arabic::latin_visual(text, direction) else { return 0.0 }; + let units: u64 = visual.chars().map(|c| u64::from(font.char_width(c))).sum(); units as f32 * size / 1000.0 } @@ -446,6 +516,9 @@ pub struct Page { /// `save` calls refused at the nesting limit; the same number of `restore` calls are ignored. refused: u32, links: Vec, + arabic: arabic::Subset, + error: Option, + missing_arabic_font: bool, } /// Most bytes of content per page before further drawing is dropped (keeps hostile inputs from @@ -706,64 +779,132 @@ 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)> { + self.text_vertical_extents_with_direction(font, size, text, TextDirection::Auto) + } + + /// Arabic ink extents with an explicit paragraph base direction. + pub fn text_vertical_extents_with_direction(&mut self, font: Font, size: f32, text: &str, direction: TextDirection) -> Option<(f32, f32)> { + if !arabic::shaped(font, text) { + return None; + } + match arabic::vertical_extents(font, text, direction) { + 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(Point::new(x, y), size, font, color, text, 0.0); + self.text_with_direction(Point::new(x, y), size, font, color, text, TextDirection::Auto); + } + + /// Text drawn with an explicit paragraph base direction. + pub fn text_with_direction(&mut self, origin: Point, size: f32, font: Font, color: Rgb, text: &str, direction: TextDirection) { + self.text_rotated_with_direction(Baseline { origin, angle: 0.0 }, size, font, color, text, direction); } /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate /// system) about its baseline origin. pub fn text_rotated(&mut self, origin: Point, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { + self.text_rotated_with_direction(Baseline { origin, angle }, size, font, color, text, TextDirection::Auto); + } + + /// Rotated text with an explicit paragraph base direction. + pub fn text_rotated_with_direction(&mut self, baseline: Baseline, size: f32, font: Font, color: Rgb, text: &str, direction: TextDirection) { + let Baseline { origin, angle } = baseline; if text.is_empty() || !extent(size) || !finite(&[origin.x, origin.y]) { return; } - let fi = font.index(); - let size = n(size.min(10_000.0)); + let direction = if direction == TextDirection::Context { direction.resolve(text) } else { direction }; 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 tm = format!("{} {} {} {} {} {} Tm\n", n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y))); - // Symbol text is one run in Symbol's own encoding (the CID font has no maths symbols). - if font == Font::Symbol { - if let Some(f) = self.fonts.get_mut(fi) { + let matrix = format!("{} {} {} {} {} {}", n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y))); + let size = size.min(10_000.0); + let shaping = arabic::shaping(font, text); + self.missing_arabic_font |= shaping == arabic::Shaping::NoFont; + if shaping == arabic::Shaping::Shaped { + self.shaped_text(&matrix, size, font, color, text, direction); + } else { + self.standard_text(&matrix, size, font, color, text, direction); + } + } + + fn shaped_text(&mut self, matrix: &str, size: f32, font: Font, color: Rgb, text: &str, direction: TextDirection) { + if self.saved.len() >= MAX_SAVE_DEPTH { + return; + } + let body = match self.arabic.text(font, text, size, direction) { + Ok(body) => body, + Err(error) => { + self.error.get_or_insert(error); + return; + } + }; + 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; } - if self.full() { + } + let mut s = format!("q\n{matrix} cm\n"); + push_rgb(&mut s, color, false); + s.push(' '); + push_rgb(&mut s, color, true); + let _ = write!(s, "\n{body}\nQ"); + self.text_op(&s); + } + + fn standard_text(&mut self, matrix: &str, size: f32, font: Font, color: Rgb, text: &str, direction: TextDirection) { + let visual = match arabic::latin_visual(text, direction) { + Ok(visual) => visual, + Err(error) => { + self.error.get_or_insert(error); return; } - let mut s = String::new(); - self.set_fill(&mut s, color); - let _ = write!(s, "BT\n{tm}/F{} {} Tf\n{} Tj\nET", fi + 1, size, pdf_string_in(text, true)); - self.op(&s); - return; - } + }; + let fi = font.index(); + let size = n(size); + let mut runs = String::new(); // Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj` // advances the text position, so the runs follow each other on the baseline. - let mut runs = String::new(); - let mut rest = text; + // Symbol text is one run in Symbol's own encoding (the CID font has no maths symbols). + let symbol = font == Font::Symbol; + let uses_cid = |c: char| !symbol && needs_cid(c); + let mut rest: &str = &visual; while let Some(first) = rest.chars().next() { - let cid = needs_cid(first); - let end = rest.char_indices().find(|(_, c)| needs_cid(*c) != cid).map_or(rest.len(), |(i, _)| i); + let cid = uses_cid(first); + let end = rest.char_indices().find(|(_, c)| uses_cid(*c) != cid).map_or(rest.len(), |(i, _)| i); let (run, tail) = rest.split_at(end); - let (slot, string) = if cid { (CID_FONT, pdf_utf16_string(run)) } else { (fi, pdf_string(run)) }; + let (slot, string) = if cid { (CID_FONT, pdf_utf16_string(run)) } else { (fi, pdf_string_in(run, symbol)) }; if let Some(f) = self.fonts.get_mut(slot) { *f = true; } let first_run = runs.is_empty(); let _ = writeln!(runs, "/F{} {} Tf", slot + 1, size); if first_run { - runs.push_str(&tm); + let _ = writeln!(runs, "{matrix} Tm"); } let _ = writeln!(runs, "{string} Tj"); rest = tail; } - if self.full() { - return; + let mut s = String::new(); + self.set_fill(&mut s, color); + let _ = write!(s, "BT\n{runs}ET"); + let s = if visual != text { format!("/Span << /ActualText {} >> BDC\n{s}\nEMC", arabic::unicode_string(text, true)) } else { s }; + self.text_op(&s); + } + + fn text_op(&mut self, s: &str) { + if self.content.len().saturating_add(s.len()).saturating_add(1) > MAX_PAGE_CONTENT { + self.error.get_or_insert_with(|| Error("PDF page text exceeds the size limit".into())); + } else { + self.op(s); } - // The colour goes in front of the text object, so it is tracked like any other style. - let mut colour = String::new(); - self.set_fill(&mut colour, color); - let s = format!("{colour}BT\n{runs}ET"); - self.op(&s); } /// Draws an RGBA image (`pw`×`ph` pixels, row-major, straight alpha) into the box @@ -957,6 +1098,9 @@ impl PdfDoc { style: Style::default(), saved: Vec::new(), refused: 0, + arabic: arabic::Subset::default(), + error: None, + missing_arabic_font: false, }); let i = self.pages.len() - 1; &mut self.pages[i] @@ -971,6 +1115,12 @@ impl PdfDoc { self.pages.get_mut(i) } + /// Whether some Arabic text was set in the standard and CID fonts, which have no Arabic glyphs, + /// because no Arabic face was found for its weight. + pub fn missing_arabic_font(&self) -> bool { + self.pages.iter().any(|page| page.missing_arabic_font) + } + /// Serializes the document. A document without pages gets one blank Letter page. /// /// Every object is written straight into the output buffer (nothing is staged in an @@ -978,7 +1128,12 @@ impl PdfDoc { /// the ids are contiguous from 1. Objects are therefore in the file in the order they are /// numbered during writing (fonts, then per page its new images, content stream and page /// dictionary), with the catalog, page tree and info dictionary last. - pub fn finish(mut self) -> Vec { + /// + /// Returns an error if the Arabic font is unusable or Arabic text exceeds a size limit. + pub fn finish(mut self) -> Result, Error> { + if let Some(error) = self.pages.iter().find_map(|page| page.error.clone()) { + return Err(error); + } if self.pages.is_empty() { self.add_page(612.0, 792.0); } @@ -1055,6 +1210,8 @@ impl PdfDoc { // confirmed by comparing the pixels before an object is shared. let mut written: HashMap> = HashMap::new(); for (pi, ((page, content), links)) in self.pages.iter().zip(contents).zip(page_links).enumerate() { + let arabic_fonts = page.arabic.write_objects(&mut next, &mut out, &mut offsets); + let page_fonts = format!("{font_dict}{arabic_fonts}"); let mut xobjects = String::new(); for (k, img) in page.images.iter().enumerate() { let known = written.get(&img.hash).and_then(|c| { @@ -1118,7 +1275,7 @@ impl PdfDoc { "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /Font << {}>>{} >> /Contents {} 0 R{} >>", n(page.width), n(page.height), - font_dict, + page_fonts, xo, content_id, annots @@ -1163,7 +1320,7 @@ impl PdfDoc { let _ = writeln!(Utf8(&mut out), "{off:010} 00000 n "); } let _ = write!(Utf8(&mut out), "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref); - out + Ok(out) } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b86300ff0d..b8b2fdb308 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -5,6 +5,36 @@ use std::io; mod reference; use reference::{OldPage, interpret}; +#[test] +fn rtl_marked_text_limit_fails_without_appending_partial_content() { + let mut doc = PdfDoc::new(); + let page = doc.add_page(600.0, 300.0); + page.content = " ".repeat(MAX_PAGE_CONTENT - 8); + let before = page.content.len(); + page.text_with_direction(Point::new(10.0, 20.0), 12.0, Font::Helvetica, [0, 0, 0], "ABC !", TextDirection::RightToLeft); + assert_eq!(page.content.len(), before, "the marked-content block must append atomically"); + assert!(doc.finish().unwrap_err().to_string().contains("size limit")); +} + +#[test] +fn a_paragraph_separator_keeps_right_to_left_latin_on_one_line() { + let visual = |text| arabic::latin_visual(text, TextDirection::RightToLeft).unwrap().into_owned(); + assert_eq!(visual("ABC\r"), visual("ABC ")); + assert_eq!(visual("ABC\u{2029}DEF"), visual("ABC DEF")); +} + +#[test] +fn cjk_in_a_right_to_left_cell_keeps_the_cid_font() { + let mut doc = PdfDoc::new(); + doc.add_page(600.0, 300.0).text_with_direction(Point::new(10.0, 20.0), 12.0, Font::Helvetica, [0, 0, 0], "编号!", TextDirection::RightToLeft); + let pdf = doc.finish().unwrap(); + check_xref(&pdf).unwrap(); + let cjk = find(&pdf, b"<7F1653F7> Tj").expect("CJK run set in the CID font"); + assert!(find(&pdf, b"/Encoding /UniGB-UTF16-H").is_some()); + assert!(find(&pdf, b"(!) Tj").is_some_and(|bang| bang < cjk), "the trailing ! is drawn left of the CJK run"); + assert_eq!(utf16_entry(&pdf, "/ActualText").unwrap(), "编号!"); +} + fn find(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) } @@ -43,7 +73,7 @@ fn check_xref(pdf: &[u8]) -> Result> { #[test] fn minimal_document() -> Result<(), Box> { - let pdf = PdfDoc::new().finish(); + let pdf = PdfDoc::new().finish()?; assert!(pdf.starts_with(b"%PDF-1.7")); assert!(pdf.ends_with(b"%%EOF\n")); assert!(check_xref(&pdf)? >= 5); @@ -69,7 +99,7 @@ fn drawing_and_text() -> Result<(), Box> { 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()?; check_xref(&pdf)?; assert!(find(&pdf, b"(Hello \\(world\\) \\\\ caf\\351 \\2005) Tj").is_some()); assert!(find(&pdf, b"(Second) Tj").is_some()); @@ -95,7 +125,7 @@ fn uri_and_page_links_are_real_pdf_annotations() -> Result<(), Box> { 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(Rect::new(0.0, 0.0, 10.0, 10.0), 99, Point::new(0.0, 0.0)); - let pdf = d.finish(); + let pdf = d.finish()?; check_xref(&pdf)?; let s = String::from_utf8_lossy(&pdf); assert_eq!(s.matches("/Subtype /Link").count(), 3); @@ -119,7 +149,7 @@ fn images_with_alpha() -> Result<(), Box> { p.image_rgba(Rect::new(10.0, 10.0, 40.0, 40.0), &px, [4, 4]); p.image_rgba(Rect::new(10.0, 10.0, 40.0, 40.0), &[255; 16], [2, 2]); // opaque: no mask p.image_rgba(Rect::new(0.0, 0.0, 1.0, 1.0), &[1, 2, 3], [4, 4]); // too short: ignored - let pdf = d.finish(); + let pdf = d.finish()?; check_xref(&pdf)?; assert!(find(&pdf, b"/SMask").is_some()); assert!(find(&pdf, b"/Im1 Do").is_some()); @@ -187,7 +217,7 @@ fn hostile_inputs_never_panic() -> Result<(), Box> { p.restore(); p.save(); p.image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &[], [u32::MAX, u32::MAX]); - let pdf = d.finish(); + let pdf = d.finish()?; check_xref(&pdf)?; Ok(()) } @@ -427,7 +457,7 @@ fn corpus_doc(seed: u64) -> (Vec, Vec) { } old_pages.push(old.content); } - (doc.finish(), old_pages) + (doc.finish().unwrap(), old_pages) } #[test] @@ -471,7 +501,7 @@ fn structure_is_valid_for_all_documents() -> Result<(), Box> { check_xref(&pdf)?; parse_objects(&pdf); // every /Length matches its data } - let empty = PdfDoc::new().finish(); + let empty = PdfDoc::new().finish()?; check_xref(&empty)?; parse_objects(&empty); Ok(()) @@ -487,7 +517,7 @@ fn images_decode_to_their_pixels() { p.image_rgba(Rect::new(1.0, 1.0, 20.0, 20.0), px, [*w, *h]); } } - let pdf = d.finish(); + let pdf = d.finish().unwrap(); let (objs, pages) = parse_pages(&pdf); assert_eq!(pages.len(), 2); for page in &pages { @@ -514,7 +544,7 @@ fn opaque_images_have_no_soft_mask() { let p = d.add_page(100.0, 100.0); p.image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &[255; 4 * 12], [4, 3]); assert!(p.images[0].alpha.is_none()); - let pdf = d.finish(); + let pdf = d.finish().unwrap(); assert!(find(&pdf, b"/SMask").is_none()); assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 1); @@ -523,7 +553,7 @@ fn opaque_images_have_no_soft_mask() { px[translucent * 4 + 3] = 7; let mut d = PdfDoc::new(); d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &px, [4, 3]); - let (objs, pages) = parse_pages(&d.finish()); + let (objs, pages) = parse_pages(&d.finish().unwrap()); let (_, _, _, alpha) = decode_image(&objs, pages[0].xobjects[0].1); let mut want = vec![255u8; 12]; want[translucent] = 7; @@ -542,7 +572,7 @@ fn identical_images_are_stored_once() -> Result<(), Box> { p.image_rgba(Rect::new(1.0, 1.0, 20.0, 20.0), px, [*w, *h]); p.image_rgba(Rect::new(30.0, 1.0, 20.0, 20.0), px, [*w, *h]); // twice on one page } - let pdf = d.finish(); + let pdf = d.finish()?; check_xref(&pdf)?; assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2, "one image + one soft mask"); let (_, pages) = parse_pages(&pdf); @@ -567,7 +597,7 @@ fn different_images_are_not_merged() { p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &b, [4, 1]); p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [2, 2]); d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [4, 1]); - let pdf = d.finish(); + let pdf = d.finish().unwrap(); assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 6, "three distinct pictures, each with a soft mask (alpha 50)"); } @@ -587,7 +617,7 @@ fn many_distinct_images_on_one_page_are_not_compared_pairwise() { assert_eq!(p.images.len(), N as usize); assert!(p.content.ends_with("/Im1 Do Q\n"), "the repeated picture reuses /Im1"); assert!(p.image_comparisons <= 1, "{} pixel comparisons for {N} distinct pictures", p.image_comparisons); - let pdf = d.finish(); + let pdf = d.finish().unwrap(); assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), N as usize); } @@ -601,7 +631,7 @@ fn hash_collisions_do_not_merge_images() { for p in 0..2 { d.page_mut(p).unwrap().images[0].hash = 42; // forced collision } - let (objs, pages) = parse_pages(&d.finish()); + let (objs, pages) = parse_pages(&d.finish().unwrap()); let ids: Vec = pages.iter().map(|p| p.xobjects[0].1).collect(); assert_ne!(ids[0], ids[1]); assert_eq!(decode_image(&objs, ids[0]).2, vec![10; 12]); @@ -706,7 +736,7 @@ fn save_nesting_is_limited_to_28() -> Result<(), Box> { p.restore(); p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &[9; 16], [2, 2]); assert!(p.content.contains(" Do")); - let pdf = d.finish(); + let pdf = d.finish()?; check_xref(&pdf)?; Ok(()) } @@ -719,7 +749,7 @@ fn finish_closes_open_saves() -> Result<(), Box> { for _ in 0..33 { p.save(); } - let (_, pages) = parse_pages(&d.finish()); + let (_, pages) = parse_pages(&d.finish()?); let i = interpret(&pages[0].content); assert!(i.balanced); assert_eq!(i.qs, 28); @@ -825,7 +855,7 @@ fn every_draw_method_survives_hostile_numbers() -> Result<(), Box> { p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(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()?; check_xref(&pdf)?; Ok(()) } @@ -848,7 +878,7 @@ fn info_strings_with_non_ascii_are_utf16be() -> Result<(), Box> { 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()?; assert_eq!(utf16_entry(&pdf, "/Title")?, "Übersicht €5 – “Q1” 日本 😀"); assert_eq!(utf16_entry(&pdf, "/Author")?, "Müller & Søn"); Ok(()) @@ -859,7 +889,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()); } @@ -869,7 +899,7 @@ fn info_strings_are_capped() -> Result<(), Box> { 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()?; assert_eq!(utf16_entry(&pdf, "/Title")?.chars().count(), MAX_INFO_CHARS); Ok(()) } @@ -880,7 +910,7 @@ fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box> { 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()?; check_xref(&pdf)?; assert!(find(&pdf, b"(????) Tj").is_none(), "CJK text written as question marks"); assert!(find(&pdf, b"<7B7E5B576E055355> Tj").is_some()); @@ -893,12 +923,284 @@ fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box> { Ok(()) } +#[test] +fn arabic_is_shaped_and_preserved_or_falls_back_without_a_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, TextDirection::Auto); + let Ok(layout) = layout else { + assert!(doc.missing_arabic_font()); + check_xref(&doc.finish().unwrap()).unwrap(); + 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).unwrap(); + assert_eq!(utf16_entry(&pdf, "/ActualText").unwrap(), 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, "مرحبا", TextDirection::Auto).is_err() && arabic::ARABIC_FONTS.is_empty() { + return; + } + 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).unwrap(); + 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, "مرحبا", TextDirection::Auto).is_err() && arabic::ARABIC_FONTS.is_empty() { + return; + } + 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).unwrap(); + 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").unwrap(), text); + assert!(text_width(Font::Helvetica, 20.0, text) >= text_width(Font::Helvetica, 20.0, "مرحبا") + 40.0, "two full-width ideographs"); + for (text, cid_run) in [("مرحبا 😀", &b" Tj"[..]), ("مرحبا Привет", &b"<041F04400438043204350442> Tj"[..])] { + let mut uncovered = PdfDoc::new(); + uncovered.add_page(600.0, 300.0).text(30.0, 70.0, 20.0, Font::Helvetica, [0, 0, 0], text); + assert!(!uncovered.missing_arabic_font()); + let pdf = uncovered.finish().unwrap(); + check_xref(&pdf).unwrap(); + assert!(find(&pdf, cid_run).is_some(), "{text}: characters the Arabic face lacks fall back like non-Arabic text"); + assert_eq!(utf16_entry(&pdf, "/ActualText").unwrap(), text); + } +} + +#[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)); + if doc.missing_arabic_font() { + check_xref(&doc.finish().unwrap()).unwrap(); + return; + } + assert!(doc.finish().unwrap_err().to_string().contains("128 KiB")); +} + +#[test] +fn arabic_joining_marks_styles_and_rotation() { + if arabic::layout(Font::Helvetica, "مرحبا", TextDirection::Auto).is_err() && arabic::ARABIC_FONTS.is_empty() { + return; + } + for (text, expected) in [("مرحبا 123 (ABC)", "(ABC)"), ("مرحبا [ABC]", "[ABC]"), ("مرحبا ", "")] { + let mixed = arabic::layout(Font::Helvetica, text, TextDirection::Auto).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, "سلام", TextDirection::Auto).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, "مَرْحَبًا", TextDirection::Auto).unwrap(); + let plain = arabic::layout(Font::Helvetica, "مرحبا", TextDirection::Auto).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, "مرحبا", TextDirection::Auto) { + 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(Point::new(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).unwrap(); + assert_eq!(utf16_entry(&rotated, "/ActualText").unwrap(), "مرحبا 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, "مرحبا", TextDirection::Auto).is_err() && arabic::ARABIC_FONTS.is_empty() { + return; + } + for (text, direction) in ["مرحبا\r\nعالم", "مرحبا\u{2028}عالم", "مرحبا\u{2029}ABC\r"] + .into_iter() + .flat_map(|text| [TextDirection::Auto, TextDirection::LeftToRight, TextDirection::RightToLeft].map(|direction| (text, direction))) + { + let layout = arabic::layout(Font::Helvetica, text, direction).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, TextDirection::RightToLeft).unwrap().items); + assert_eq!(drawn("مرحبا\u{2029}عالم"), drawn("مرحبا عالم"), "a paragraph separator keeps right-to-left reading order"); +} + +#[test] +fn arabic_text_respects_the_save_nesting_limit() -> Result<(), Box> { + if arabic::layout(Font::Helvetica, "مرحبا", TextDirection::Auto).is_err() && arabic::ARABIC_FONTS.is_empty() { + return Ok(()); + } + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + for _ in 0..28 { + p.save(); + } + p.text(10.0, 50.0, 12.0, Font::Helvetica, [0, 0, 0], "مرحبا"); + assert_eq!(interpret(&p.content).max_depth, 28); + p.restore(); + p.text(10.0, 50.0, 12.0, Font::Helvetica, [0, 0, 0], "مرحبا"); + assert!(p.content.contains("/ActualText")); + check_xref(&d.finish()?)?; + Ok(()) +} + +#[test] +fn arabic_subsets_split_after_255_character_codes() -> Result<(), Box> { + if arabic::layout(Font::Helvetica, "مرحبا", TextDirection::Auto).is_err() && arabic::ARABIC_FONTS.is_empty() { + return Ok(()); + } + 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).unwrap(); + 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"); + Ok(()) +} + +#[test] +fn spreadsheet_context_is_first_character_not_first_strong() { + use TextDirection::{Auto, Context, LeftToRight as Ltr, RightToLeft as Rtl}; + for (text, expected) in [(" مرحبا", Rtl), ("123 مرحبا", Ltr), ("(مرحبا)", Ltr), (" ١٢٣ مرحبا", Ltr), ("\t\n", Ltr)] { + assert_eq!(Context.resolve(text), expected, "{text}"); + } + assert_eq!(Auto.resolve("123 مرحبا"), Rtl); + assert_eq!(Auto.resolve("(مرحبا)"), Rtl); +} + +#[test] +fn explicit_rtl_standard_text_reorders_without_an_arabic_font() { + let source = "ABC !"; + let mut doc = PdfDoc::new(); + let page = doc.add_page(300.0, 100.0); + page.text_with_direction(Point::new(10.0, 30.0), 12.0, Font::Helvetica, [0, 0, 0], source, TextDirection::RightToLeft); + assert!(page.content.contains("(! ABC) Tj"), "{}", page.content); + assert!(page.arabic.write_objects(&mut 4, &mut Vec::new(), &mut Vec::new()).is_empty()); + assert_eq!(text_width_with_direction(Font::Helvetica, 12.0, source, TextDirection::RightToLeft), text_width(Font::Helvetica, 12.0, source)); + let pdf = doc.finish().unwrap(); + assert_eq!(utf16_entry(&pdf, "/ActualText").unwrap(), source); + assert!(find(&pdf, b"/Subtype /Type3").is_none()); + + let mut huge = PdfDoc::new(); + huge.add_page(300.0, 100.0).text_with_direction( + Point::new(10.0, 30.0), + 12.0, + Font::Helvetica, + [0, 0, 0], + &"A".repeat(128 * 1024 + 1), + TextDirection::RightToLeft, + ); + assert!(huge.finish().unwrap_err().to_string().contains("128 KiB")); +} + +#[test] +fn arabic_explicit_base_changes_visual_order_and_preserves_logical_text() { + if arabic::layout(Font::Helvetica, "مرحبا", TextDirection::Auto).is_err() && arabic::ARABIC_FONTS.is_empty() { + return; + } + let sources = |text: &str, direction| { + arabic::layout(Font::Helvetica, text, direction) + .unwrap() + .items + .into_iter() + .map(|item| match item { + arabic::Item::Glyph { source, .. } => source, + arabic::Item::Latin { text, .. } | arabic::Item::Cid { text, .. } => text, + }) + .collect::() + }; + let text = "مرحبا ABC"; + let ltr = sources(text, TextDirection::LeftToRight); + let rtl = sources(text, TextDirection::RightToLeft); + assert!(ltr.ends_with("ABC"), "{ltr}"); + assert!(rtl.starts_with("ABC"), "{rtl}"); + assert_ne!(ltr, rtl); + assert_eq!(sources("123 مرحبا", TextDirection::Context), sources("123 مرحبا", TextDirection::LeftToRight)); + let mut documents = Vec::new(); + for direction in [TextDirection::LeftToRight, TextDirection::RightToLeft] { + let mut doc = PdfDoc::new(); + doc.add_page(300.0, 100.0).text_rotated_with_direction( + Baseline { origin: Point::new(30.0, 70.0), angle: 0.3 }, + 20.0, + Font::Helvetica, + [0, 0, 0], + text, + direction, + ); + let pdf = doc.finish().unwrap(); + assert_eq!(utf16_entry(&pdf, "/ActualText").unwrap(), text); + documents.push(pdf); + } + assert_ne!(documents[0], documents[1]); +} + #[test] fn many_alternating_runs_share_one_text_matrix() { let mut doc = PdfDoc::new(); let p = doc.add_page(612.0, 792.0); p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], &"a\u{4e2d}".repeat(2000)); - let pdf = doc.finish(); + let pdf = doc.finish().unwrap(); let count = |needle: &[u8]| pdf.windows(needle.len()).filter(|w| *w == needle).count(); assert_eq!(count(b" Tm\n"), 1, "the position is set once, before the first run"); assert_eq!(count(b" Tf\n"), 4000, "every run selects its font"); @@ -906,19 +1208,34 @@ fn many_alternating_runs_share_one_text_matrix() { } #[test] -fn symbol_font_draws_maths_in_its_own_encoding() { +fn symbol_font_draws_maths_in_its_own_encoding() -> Result<(), Box> { let mut doc = PdfDoc::new(); let p = doc.add_page(200.0, 200.0); p.text(10.0, 50.0, 12.0, Font::Symbol, [0, 0, 0], "\u{2211}\u{03B1}\u{2264}\u{221E}x"); p.text(10.0, 80.0, 12.0, Font::TimesItalic, [0, 0, 0], "x"); - let pdf = doc.finish(); + let pdf = doc.finish()?; let s = String::from_utf8_lossy(&pdf); assert!(s.contains("/BaseFont /Symbol >>"), "Symbol has no WinAnsi encoding"); assert!(s.contains("/BaseFont /Times-Italic /Encoding /WinAnsiEncoding")); // The sum, alpha, less-or-equal and infinity codes; x is not in Symbol and becomes `?`. assert!(s.contains("(\\345a\\243\\245?) Tj"), "{s}"); - check_xref(&pdf).unwrap(); + check_xref(&pdf)?; assert!(Font::Symbol.can_encode('\u{2211}') && !Font::Symbol.can_encode('x')); assert!(Font::TimesRoman.can_encode('x') && !Font::TimesRoman.can_encode('\u{2211}')); assert_eq!(text_width(Font::Symbol, 10.0, "\u{2211}"), 7.13); + Ok(()) +} + +#[test] +fn symbol_text_is_never_shaped_as_arabic() -> Result<(), Box> { + let mut doc = PdfDoc::new(); + let page = doc.add_page(200.0, 200.0); + assert_eq!(page.text_vertical_extents(Font::Symbol, 12.0, "\u{03B1}ب"), None); + page.text(10.0, 50.0, 12.0, Font::Symbol, [0, 0, 0], "\u{03B1}ب"); + let pdf = doc.finish()?; + check_xref(&pdf)?; + assert!(find(&pdf, b"(a?) Tj").is_some()); + assert!(find(&pdf, b"/ActualText").is_none()); + assert_eq!(text_width(Font::Symbol, 10.0, "\u{03B1}ب"), f32::from(Font::Symbol.char_width('\u{03B1}') + Font::Symbol.char_width('ب')) / 100.0); + Ok(()) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0847714a3a..20e4d9baf8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls data validation is read; earlier: .xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .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; earlier: `file.exportCsv {values: true}` writes raw values for automation; .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; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-13 · **Change:** minor (PDF honours cell reading direction and mirrors right-to-left sheets; earlier: Arabic PDF export no longer fails when a character or the Arabic font is missing; earlier: Arabic PDF text is shaped with embedded Noto Arabic glyphs beside the non-embedded standard, Symbol and CID fonts; earlier: .xls data validation is read; earlier: .xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .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; earlier: `file.exportCsv {values: true}` writes raw values for automation; .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; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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. Characters the Arabic face lacks fall back to the standard and CID fonts, and without an Arabic face for the text's weight Arabic is set in the CID font, which has no Arabic glyphs, with a message. Cell Context/LTR/RTL direction is carried through measurement, wrapping and drawing. RTL sheet geometry mirrors while text stays readable. Latin-only RTL text uses bidi ordering without requiring an Arabic font. + Cell text now uses shared Unicode bidi layout while retaining logical source indexes for editing, copy and XLSX. Format Cells exposes Context, Left to right and Right to left reading direction, independent of sheet geometry. OOXML Context uses the first non-whitespace character. Optional direction preserves absent versus explicit Context in differential styles. The renderer is pinned to a reviewed egui 0.36.2 backport with real-font, editing, accessibility and WASM checks. Physical screen-reader behavior remains unverified. ## Workbook window settings @@ -122,7 +124,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | 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. No font is embedded: text uses the standard Helvetica, Times and Courier faces (13 base-14 entries with Symbol), and characters outside WinAnsiEncoding are set in a predefined, non-embedded CID font (STSong-Light, Adobe-GB1, `UniGB-UTF16-H`), so **Simplified Chinese displays where the reader supplies that font, and scripts outside Adobe-GB1 (Arabic, Devanagari, Thai) export as code points but show as the reader's missing-glyph box**; equations use Symbol for the Greek letters and operators it has, the Times faces for letters and digits, and a `[?]` box for the rest; 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 standard Helvetica, Times and Courier faces (13 base-14 entries with Symbol), not embedded; Arabic exports as shaped, embedded Noto Arabic glyphs; cell reading direction and RTL sheet geometry are kept; other characters outside WinAnsiEncoding are set in a predefined, non-embedded CID font (STSong-Light, Adobe-GB1, `UniGB-UTF16-H`, #1912), so **Simplified Chinese displays where the reader supplies that font, and scripts outside Adobe-GB1 (Devanagari, Thai) export as code points but show as the reader's missing-glyph box**; equations use Symbol for the Greek letters and operators it has, the Times faces for letters and digits, and a `[?]` box for the rest; 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) | Pages print in the sheet's page order: down, then over (the default) or over, then down | | Images of ranges/charts (Copy as Picture) | yes | chart raster in `crates/chart` | No range-to-image command | @@ -161,9 +163,12 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | PDF of a right-to-left sheet keeps explicit Left and Right alignment on the side the grid shows them | | 2026-11-12 | minor | `.xls` files open with their data validation (drop-down lists, input messages, error alerts and the rules), read by craft-xls and mapped as the `.xlsx` reader maps the same rules. A rule Excel would refuse, or one past 65,536 in a workbook, is skipped with a warning (#3634, from #3636) | | 2026-11-12 | minor | .xls import reads the file with craft-xls (storytold/craft-libs), the reader that moved there from this repository; GridCraft keeps the mapping onto its model. The workbook now opens on the sheet it was saved on (WINDOW1 `itabCur`), falling back to the first visible sheet as before (from #3551), and cells saved as RString records (text with formatting runs) are read as text instead of skipped | | 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) | +| 2026-11-09 | minor | Arabic PDF export no longer fails on a character the Arabic face lacks (it falls back like other text) or when no Arabic font is available (Arabic is set in the CID font, which has no Arabic glyphs, and a message says so) | +| 2026-11-03 | trivial | The PDF row lists Arabic's shaped, embedded Noto glyphs beside the non-embedded standard faces, Symbol and the Adobe-GB1 CID font; Symbol text keeps its own encoding and is never shaped as Arabic | | 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) | | 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) | | 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) | @@ -185,6 +190,8 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | | 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | +| 2026-10-26 | minor | PDF honors cell Context/LTR/RTL, preserves paragraph direction across wrapping, and mirrors RTL sheet geometry without reversing glyphs | +| 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-26 | minor | Alignment readingOrder 0/1/2 survives cell, named-style and differential-style round-trips; Format Cells exposes reading direction and CF priority preserves unspecified inheritance | | 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`) | diff --git a/docs/gaps.md b/docs/gaps.md index d2c89d86af..3b21efc69b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (§17: equations; earlier: §13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-13 · **Change:** minor (§5: Arabic PDF output preserves cell direction, joined glyphs and RTL sheet geometry; earlier: §17: equations; earlier: §13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -54,17 +54,17 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) ### 5. Text in non-Latin scripts doesn't render or export (L) -- **Missing:** cross-platform script coverage, Devanagari/Thai shaping verification, non-Latin PDF and Arabic UI layout. +- **Missing:** cross-platform script coverage, Devanagari/Thai shaping verification, non-Latin PDF outside Arabic and Arabic UI layout. - **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32); #142 (Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora). Arabic shared shaping/bidi, editing and cell Context/LTR/RTL now have focused regressions and full gates. Arabic font delivery and PDF output are separate PRs; other script shaping still needs verification. -- **Missing:** CJK font fallback, complex shaping (Arabic, Devanagari, Thai), bidi, non-Latin PDF. +- **Missing:** CJK font fallback, complex shaping (Arabic, Devanagari, Thai), bidi, non-Latin PDF outside Arabic. - **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32), and for Arabic (system, fontconfig and craft-fonts faces); a UI font with Arabic of its own (DejaVu Sans in the Flatpak) still draws interface Arabic ahead of fontconfig's choice (#229); #142 (Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora); no shaping for Arabic or - Devanagari; PDF writer uses 14 standard fonts only. + Devanagari in the UI. PDF now shapes Arabic with licensed glyph outlines, preserves cell reading direction, and mirrors RTL sheets. - **Impact:** blocks most of the world's users even with an English UI. - **Estimate:** 12–20 h. [localization-parity.md](localization-parity.md) @@ -200,6 +200,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. | | 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model | | 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog | +| 2026-10-26 | minor | §5: Arabic PDF output preserves cell direction, joined glyphs and RTL sheet geometry. Other script and UI gaps remain. | | 2026-10-26 | minor | Arabic shared bidi display/editing and persisted cell reading direction; other script and PDF work remain separate | | 2026-10-26 | minor | §5: Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work | | 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 1de2927ac6..f886d10042 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-11-10 · **Change:** minor (Ukrainian Chart is Діаграма); previously minor (Diagram and Decorative Text labels, Diagram distinct from Chart); previously minor (CJK fallback baseline alignment); previously minor (Arabic bidi display, editing and cell reading direction; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (number formats honour the `B1`/`B2` calendar prefixes, checked against Excel; earlier: missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks); previously minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Arabic PDF export no longer fails when a character or the Arabic font is missing; Arabic PDF cells keep their reading direction and RTL pages mirror); previously minor (Ukrainian Chart is Діаграма); previously minor (Diagram and Decorative Text labels, Diagram distinct from Chart); previously minor (CJK fallback baseline alignment); previously minor (Arabic bidi display, editing and cell reading direction; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (number formats honour the `B1`/`B2` calendar prefixes, checked against Excel; earlier: missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks); previously minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **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. Characters the Arabic face lacks fall back to the standard and CID fonts, and without an Arabic face for the text's weight Arabic is set in the CID font, which has no Arabic glyphs, with a message. Cell Context/LTR/RTL direction is carried through measurement, wrapping and drawing. RTL sheet geometry mirrors while text stays readable. Latin-only RTL text uses bidi ordering without requiring an Arabic font. + Cell text now uses shared Unicode bidi layout while retaining logical source indexes for editing, copy and XLSX. Format Cells exposes Context, Left to right and Right to left reading direction, independent of sheet geometry. OOXML Context uses the first non-whitespace character. Optional direction preserves absent versus explicit Context in differential styles. The renderer is pinned to a reviewed egui 0.36.2 backport with real-font, editing, accessibility and WASM checks. Physical screen-reader behavior remains unverified. ## Where we are (measured from source on `main`, after the 2026-10-10 merges) @@ -74,14 +76,15 @@ Cell text now uses shared Unicode bidi layout while retaining logical source ind (`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 follows its cell reading direction inside its box, - and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet. + and the sheet tabs and horizontal scrollbar are not mirrored yet; PDF export mirrors RTL sheets. Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`) through XLSX save, though GridCraft still draws that text left to right. IME input goes through egui/winit and is unverified for Chinese, Japanese and Korean. - **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 @@ -95,7 +98,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 | shared shaping/bidi; no Arabic UI catalog or mirrored shell; Sheet RTL mirrors the grid (#146) | no | none | 25–35 h | +| Arabic | ar | 0 (0%) | no | shared shaping/bidi; no Arabic UI catalog or mirrored shell; PDF export: shaped and joined, follows cell direction, mirrors RTL sheets; Sheet RTL 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 | @@ -135,11 +138,14 @@ language (none so far) and of localized function names. |---|---|---| | 2026-11-10 | minor | Ukrainian Chart is `Діаграма`, matching [craft-i18n#211](https://github.com/storytold/craft-i18n/pull/211) and distinct from Diagram (`Схема`) | | 2026-11-10 | minor | Diagram and Decorative Text labels in all 18 non-English catalogs; Diagram never repeats the catalog's chart word (German `Schema`, Ukrainian `Схема`, Romanian `Schemă`), see [feature-names.md](feature-names.md) | +| 2026-11-09 | minor | Arabic PDF export no longer fails on a character the Arabic face lacks (it falls back like other text) or when no Arabic font is available (Arabic is set in the CID font, which has no Arabic glyphs, and a message says so) | | 2026-10-31 | minor | System and embedded CJK text fallbacks share the Latin baseline in cells, editors and window titles; other scripts, symbols and emoji keep their alignment | +| 2026-10-26 | minor | Arabic PDF cells inherit explicit or contextual direction; wrapped lines retain paragraph direction, and RTL pages mirror cell and object geometry | | 2026-10-26 | minor | Shared bidi layout supports Arabic clusters, marks, visual cursor navigation and directional accessibility; cell Context/LTR/RTL is stored separately from sheet RTL and round-trips through XLSX | | 2026-10-26 | minor | Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work | | 2026-10-27 | minor | Number formats honour the `B1`/`B2` calendar prefixes: never shown; `B2` renders year, month, day and Arabic month and weekday names in the tabular Hijri calendar (1 Jan 2001 = 6 Shawwal 1421; leap years 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 of the 30-year cycle, which is what Excel shows). Checked against Excel 16.0 (build 20527, en-US) on every serial and about 1,700 format/value pairs: a locale or currency tag before `B2` keeps the date Gregorian, a misplaced `B` and digit or a prefix before a number format is rejected, serial 60 has no Hijri date, serial 0 shows as serial 1, two-word month names use a no-break space. Not matched: Excel's weekday/month-name mix-ups in one `B2` section and its weekday-only quirks | | 2026-10-27 | minor | AutoFit column width, wrapped-row height and PivotTable column fit estimate Arabic letters at ~0.48 em (measured 0.36–0.53 em shaped, Noto Sans/Naskh Arabic and DejaVu Sans) instead of 1 em, Arabic-Indic digits as wide as ASCII digits, and give vowel marks and direction marks no width; vowelled Arabic no longer fits ~3.5× too wide | +| 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`) |