From 6257e4d6cf060f367c3f2eb2d005a091b6432e88 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 10 Oct 2024 13:59:00 +0200 Subject: [PATCH] More robust glyph drawing (#5159) --- crates/typst-pdf/src/color_font.rs | 44 +++++-- crates/typst-pdf/src/content.rs | 85 +++++++------- crates/typst-render/src/text.rs | 15 ++- crates/typst-syntax/src/span.rs | 7 ++ crates/typst/src/realize.rs | 6 +- crates/typst/src/text/font/color.rs | 173 ++++++++++++++++++---------- 6 files changed, 205 insertions(+), 125 deletions(-) diff --git a/crates/typst-pdf/src/color_font.rs b/crates/typst-pdf/src/color_font.rs index 5182a0594..f6fea3962 100644 --- a/crates/typst-pdf/src/color_font.rs +++ b/crates/typst-pdf/src/color_font.rs @@ -12,10 +12,11 @@ use indexmap::IndexMap; use pdf_writer::types::UnicodeCmap; use pdf_writer::writers::WMode; use pdf_writer::{Filter, Finish, Name, Rect, Ref}; -use typst::diag::SourceResult; +use typst::diag::{bail, error, SourceDiagnostic, SourceResult}; +use typst::foundations::Repr; use typst::layout::Em; -use typst::text::color::frame_for_glyph; -use typst::text::Font; +use typst::text::color::glyph_frame; +use typst::text::{Font, Glyph, TextItemView}; use crate::content; use crate::font::{base_font_name, write_font_descriptor, CMAP_NAME, SYSTEM_INFO}; @@ -211,9 +212,10 @@ impl ColorFontMap<()> { pub fn get( &mut self, options: &PdfOptions, - font: &Font, - gid: u16, + text: &TextItemView, + glyph: &Glyph, ) -> SourceResult<(usize, u8)> { + let font = &text.item.font; let color_font = self.map.entry(font.clone()).or_insert_with(|| { let global_bbox = font.ttf().global_bounding_box(); let bbox = Rect::new( @@ -230,7 +232,7 @@ impl ColorFontMap<()> { } }); - Ok(if let Some(index_of_glyph) = color_font.glyph_indices.get(&gid) { + Ok(if let Some(index_of_glyph) = color_font.glyph_indices.get(&glyph.id) { // If we already know this glyph, return it. (color_font.slice_ids[index_of_glyph / 256], *index_of_glyph as u8) } else { @@ -242,9 +244,13 @@ impl ColorFontMap<()> { self.total_slice_count += 1; } - let frame = frame_for_glyph(font, gid); - let width = - font.advance(gid).unwrap_or(Em::new(0.0)).get() * font.units_per_em(); + let (frame, tofu) = glyph_frame(font, glyph.id); + if options.standards.pdfa && tofu { + bail!(failed_to_convert(text, glyph)); + } + + let width = font.advance(glyph.id).unwrap_or(Em::new(0.0)).get() + * font.units_per_em(); let instructions = content::build( options, &mut self.resources, @@ -252,8 +258,8 @@ impl ColorFontMap<()> { None, Some(width as f32), )?; - color_font.glyphs.push(ColorGlyph { gid, instructions }); - color_font.glyph_indices.insert(gid, index); + color_font.glyphs.push(ColorGlyph { gid: glyph.id, instructions }); + color_font.glyph_indices.insert(glyph.id, index); (color_font.slice_ids[index / 256], index as u8) }) @@ -321,3 +327,19 @@ pub struct ColorFontSlice { /// represent the subset of the TTF font we are interested in. pub subfont: usize, } + +/// The error when the glyph could not be converted. +#[cold] +fn failed_to_convert(text: &TextItemView, glyph: &Glyph) -> SourceDiagnostic { + let mut diag = error!( + glyph.span.0, + "the glyph for {} could not be exported", + text.glyph_text(glyph).repr() + ); + + if text.item.font.ttf().tables().cff2.is_some() { + diag.hint("CFF2 fonts are not currently supported"); + } + + diag +} diff --git a/crates/typst-pdf/src/content.rs b/crates/typst-pdf/src/content.rs index 79323b6a0..babb3e57a 100644 --- a/crates/typst-pdf/src/content.rs +++ b/crates/typst-pdf/src/content.rs @@ -10,15 +10,15 @@ use pdf_writer::types::{ }; use pdf_writer::writers::PositionedItems; use pdf_writer::{Content, Finish, Name, Rect, Str}; -use typst::diag::{bail, SourceResult}; +use typst::diag::{bail, error, SourceDiagnostic, SourceResult}; use typst::foundations::Repr; use typst::layout::{ Abs, Em, Frame, FrameItem, GroupItem, Point, Ratio, Size, Transform, }; use typst::model::Destination; use typst::syntax::Span; -use typst::text::color::is_color_glyph; -use typst::text::{Font, TextItem, TextItemView}; +use typst::text::color::should_outline; +use typst::text::{Font, Glyph, TextItem, TextItemView}; use typst::utils::{Deferred, Numeric, SliceExt}; use typst::visualize::{ FillRule, FixedStroke, Geometry, Image, LineCap, LineJoin, Paint, Path, PathItem, @@ -418,46 +418,27 @@ fn write_group(ctx: &mut Builder, pos: Point, group: &GroupItem) -> SourceResult /// Encode a text run into the content stream. fn write_text(ctx: &mut Builder, pos: Point, text: &TextItem) -> SourceResult<()> { - if ctx.options.standards.pdfa { - let last_resort = text.font.info().is_last_resort(); - for g in &text.glyphs { - if last_resort || g.id == 0 { - bail!( - g.span.0, - "the text {} could not be displayed with any font", - TextItemView::full(text).glyph_text(g).repr(), - ); - } - } + if ctx.options.standards.pdfa && text.font.info().is_last_resort() { + bail!( + Span::find(text.glyphs.iter().map(|g| g.span.0)), + "the text {} could not be displayed with any font", + &text.text, + ); } - let ttf = text.font.ttf(); - let tables = ttf.tables(); + let outline_glyphs = + text.glyphs.iter().filter(|g| should_outline(&text.font, g)).count(); - // If the text run contains either only color glyphs (used for emojis for - // example) or normal text we can render it directly - let has_color_glyphs = tables.sbix.is_some() - || tables.cbdt.is_some() - || tables.svg.is_some() - || tables.colr.is_some(); - if !has_color_glyphs { - write_normal_text(ctx, pos, TextItemView::full(text))?; - return Ok(()); - } - - let color_glyph_count = - text.glyphs.iter().filter(|g| is_color_glyph(&text.font, g)).count(); - - if color_glyph_count == text.glyphs.len() { - write_color_glyphs(ctx, pos, TextItemView::full(text))?; - } else if color_glyph_count == 0 { + if outline_glyphs == text.glyphs.len() { write_normal_text(ctx, pos, TextItemView::full(text))?; + } else if outline_glyphs == 0 { + write_complex_glyphs(ctx, pos, TextItemView::full(text))?; } else { - // Otherwise we need to split it in smaller text runs + // Otherwise we need to split it into smaller text runs. let mut offset = 0; let mut position_in_run = Abs::zero(); - for (color, sub_run) in - text.glyphs.group_by_key(|g| is_color_glyph(&text.font, g)) + for (should_outline, sub_run) in + text.glyphs.group_by_key(|g| should_outline(&text.font, g)) { let end = offset + sub_run.len(); @@ -468,11 +449,12 @@ fn write_text(ctx: &mut Builder, pos: Point, text: &TextItem) -> SourceResult<() let pos = pos + Point::new(position_in_run, Abs::zero()); position_in_run += text_item_view.width(); offset = end; - // Actually write the sub text-run - if color { - write_color_glyphs(ctx, pos, text_item_view)?; - } else { + + // Actually write the sub text-run. + if should_outline { write_normal_text(ctx, pos, text_item_view)?; + } else { + write_complex_glyphs(ctx, pos, text_item_view)?; } } } @@ -534,6 +516,10 @@ fn write_normal_text( // Write the glyphs with kerning adjustments. for glyph in text.glyphs() { + if ctx.options.standards.pdfa && glyph.id == 0 { + bail!(tofu(&text, glyph)); + } + adjustment += glyph.x_offset; if !adjustment.is_zero() { @@ -596,7 +582,7 @@ fn show_text(items: &mut PositionedItems, encoded: &[u8]) { } /// Encodes a text run made only of color glyphs into the content stream -fn write_color_glyphs( +fn write_complex_glyphs( ctx: &mut Builder, pos: Point, text: TextItemView, @@ -621,12 +607,17 @@ fn write_color_glyphs( .or_default(); for glyph in text.glyphs() { + if ctx.options.standards.pdfa && glyph.id == 0 { + bail!(tofu(&text, glyph)); + } + // Retrieve the Type3 font reference and the glyph index in the font. let color_fonts = ctx .resources .color_fonts .get_or_insert_with(|| Box::new(ColorFontMap::new())); - let (font, index) = color_fonts.get(ctx.options, &text.item.font, glyph.id)?; + + let (font, index) = color_fonts.get(ctx.options, &text, glyph)?; if last_font != Some(font) { ctx.content.set_font( @@ -824,3 +815,13 @@ fn to_pdf_line_join(join: LineJoin) -> LineJoinStyle { LineJoin::Bevel => LineJoinStyle::BevelJoin, } } + +/// The error when there is a tofu glyph. +#[cold] +fn tofu(text: &TextItemView, glyph: &Glyph) -> SourceDiagnostic { + error!( + glyph.span.0, + "the text {} could not be displayed with any font", + text.glyph_text(glyph).repr(), + ) +} diff --git a/crates/typst-render/src/text.rs b/crates/typst-render/src/text.rs index c4e833406..70d516426 100644 --- a/crates/typst-render/src/text.rs +++ b/crates/typst-render/src/text.rs @@ -4,7 +4,7 @@ use pixglyph::Bitmap; use tiny_skia as sk; use ttf_parser::{GlyphId, OutlineBuilder}; use typst::layout::{Abs, Axes, Point, Size}; -use typst::text::color::{frame_for_glyph, is_color_glyph}; +use typst::text::color::{glyph_frame, should_outline}; use typst::text::{Font, TextItem}; use typst::visualize::{FixedStroke, Paint}; @@ -18,20 +18,19 @@ pub fn render_text(canvas: &mut sk::Pixmap, state: State, text: &TextItem) { let id = GlyphId(glyph.id); let offset = x + glyph.x_offset.at(text.size).to_f32(); - if is_color_glyph(&text.font, glyph) { + if should_outline(&text.font, glyph) { + let state = + state.pre_translate(Point::new(Abs::raw(offset as _), Abs::raw(0.0))); + render_outline_glyph(canvas, state, text, id); + } else { let upem = text.font.units_per_em(); let text_scale = Abs::raw(text.size.to_raw() / upem); let state = state .pre_translate(Point::new(Abs::raw(offset as _), -text.size)) .pre_scale(Axes::new(text_scale, text_scale)); - let glyph_frame = frame_for_glyph(&text.font, glyph.id); - + let (glyph_frame, _) = glyph_frame(&text.font, glyph.id); crate::render_frame(canvas, state, &glyph_frame); - } else { - let state = - state.pre_translate(Point::new(Abs::raw(offset as _), Abs::raw(0.0))); - render_outline_glyph(canvas, state, text, id); } x += glyph.x_advance.at(text.size).to_f32(); diff --git a/crates/typst-syntax/src/span.rs b/crates/typst-syntax/src/span.rs index 4d5dd9c37..85745f60e 100644 --- a/crates/typst-syntax/src/span.rs +++ b/crates/typst-syntax/src/span.rs @@ -92,6 +92,13 @@ impl Span { } } + /// Find the first non-detached span in the iterator. + pub fn find(iter: impl IntoIterator) -> Self { + iter.into_iter() + .find(|span| !span.is_detached()) + .unwrap_or(Span::detached()) + } + /// Resolve a file location relative to this span's source. pub fn resolve_path(self, path: &str) -> Result { let Some(file) = self.id() else { diff --git a/crates/typst/src/realize.rs b/crates/typst/src/realize.rs index 7c199ff16..896c2a6a8 100644 --- a/crates/typst/src/realize.rs +++ b/crates/typst/src/realize.rs @@ -1241,9 +1241,5 @@ fn destruct_space(buf: &mut [Pair], end: &mut usize, state: &mut SpaceState) { /// Finds the first non-detached span in the list. fn select_span(children: &[Pair]) -> Span { - children - .iter() - .map(|(c, _)| c.span()) - .find(|span| !span.is_detached()) - .unwrap_or(Span::detached()) + Span::find(children.iter().map(|(c, _)| c.span())) } diff --git a/crates/typst/src/text/font/color.rs b/crates/typst/src/text/font/color.rs index 6b6ae9b0d..0e6b0c1fa 100644 --- a/crates/typst/src/text/font/color.rs +++ b/crates/typst/src/text/font/color.rs @@ -6,51 +6,138 @@ use ttf_parser::{GlyphId, RgbaColor}; use usvg::tiny_skia_path; use xmlwriter::XmlWriter; -use crate::layout::{Abs, Axes, Frame, FrameItem, Point, Size}; +use crate::layout::{Abs, Frame, FrameItem, Point, Size}; use crate::syntax::Span; use crate::text::{Font, Glyph}; -use crate::visualize::Image; +use crate::visualize::{FixedStroke, Geometry, Image}; -/// Tells if a glyph is a color glyph or not in a given font. -pub fn is_color_glyph(font: &Font, g: &Glyph) -> bool { +/// Whether this glyph should be rendered via simple outlining instead of via +/// `glyph_frame`. +pub fn should_outline(font: &Font, glyph: &Glyph) -> bool { let ttf = font.ttf(); - let glyph_id = GlyphId(g.id); - ttf.glyph_raster_image(glyph_id, 160).is_some() - || ttf.glyph_svg_image(glyph_id).is_some() - || ttf.is_color_glyph(glyph_id) + let glyph_id = GlyphId(glyph.id); + (ttf.tables().glyf.is_some() || ttf.tables().cff.is_some()) + && !ttf + .glyph_raster_image(glyph_id, u16::MAX) + .is_some_and(|img| img.format == ttf_parser::RasterImageFormat::PNG) + && !ttf.is_color_glyph(glyph_id) + && ttf.glyph_svg_image(glyph_id).is_none() } -/// Returns a frame with the glyph drawn inside. +/// Returns a frame representing a glyph and whether it is a fallback tofu +/// frame. +/// +/// Should only be called on glyphs for which [`should_outline`] returns false. /// /// The glyphs are sized in font units, [`text.item.size`] is not taken into /// account. #[comemo::memoize] -pub fn frame_for_glyph(font: &Font, glyph_id: u16) -> Frame { - let ttf = font.ttf(); - let upem = Abs::pt(ttf.units_per_em() as f64); +pub fn glyph_frame(font: &Font, glyph_id: u16) -> (Frame, bool) { + let upem = Abs::pt(font.units_per_em()); let glyph_id = GlyphId(glyph_id); let mut frame = Frame::soft(Size::splat(upem)); + let mut tofu = false; - if let Some(raster_image) = ttf.glyph_raster_image(glyph_id, u16::MAX) { - draw_raster_glyph(&mut frame, font, upem, raster_image); - } else if ttf.is_color_glyph(glyph_id) { - draw_colr_glyph(&mut frame, upem, ttf, glyph_id); - } else if ttf.glyph_svg_image(glyph_id).is_some() { - draw_svg_glyph(&mut frame, upem, font, glyph_id); + if draw_glyph(&mut frame, font, upem, glyph_id).is_none() + && font.ttf().glyph_index(' ') != Some(glyph_id) + { + // Generate a fallback tofu if the glyph couldn't be drawn, unless it is + // the space glyph. Then, an empty frame does the job. (This happens for + // some rare CBDT fonts, which don't define a bitmap for the space, but + // also don't have a glyf or CFF table.) + draw_fallback_tofu(&mut frame, font, upem, glyph_id); + tofu = true; } - frame + (frame, tofu) } +/// Tries to draw a glyph. +fn draw_glyph( + frame: &mut Frame, + font: &Font, + upem: Abs, + glyph_id: GlyphId, +) -> Option<()> { + let ttf = font.ttf(); + if let Some(raster_image) = ttf + .glyph_raster_image(glyph_id, u16::MAX) + .filter(|img| img.format == ttf_parser::RasterImageFormat::PNG) + { + draw_raster_glyph(frame, font, upem, raster_image) + } else if ttf.is_color_glyph(glyph_id) { + draw_colr_glyph(frame, font, upem, glyph_id) + } else if ttf.glyph_svg_image(glyph_id).is_some() { + draw_svg_glyph(frame, font, upem, glyph_id) + } else { + None + } +} + +/// Draws a fallback tofu box with the advance width of the glyph. +fn draw_fallback_tofu(frame: &mut Frame, font: &Font, upem: Abs, glyph_id: GlyphId) { + let advance = font + .ttf() + .glyph_hor_advance(glyph_id) + .map(|advance| Abs::pt(advance as f64)) + .unwrap_or(upem / 3.0); + let inset = 0.15 * advance; + let height = 0.7 * upem; + let pos = Point::new(inset, upem - height); + let size = Size::new(advance - inset * 2.0, height); + let thickness = upem / 20.0; + let stroke = FixedStroke { thickness, ..Default::default() }; + let shape = Geometry::Rect(size).stroked(stroke); + frame.push(pos, FrameItem::Shape(shape, Span::detached())); +} + +/// Draws a raster glyph in a frame. +/// +/// Supports only PNG images. +fn draw_raster_glyph( + frame: &mut Frame, + font: &Font, + upem: Abs, + raster_image: ttf_parser::RasterGlyphImage, +) -> Option<()> { + let image = Image::new( + raster_image.data.into(), + typst::visualize::ImageFormat::Raster(typst::visualize::RasterFormat::Png), + None, + ) + .ok()?; + + // Apple Color emoji doesn't provide offset information (or at least + // not in a way ttf-parser understands), so we artificially shift their + // baseline to make it look good. + let y_offset = if font.info().family.to_lowercase() == "apple color emoji" { + 20.0 + } else { + -(raster_image.y as f64) + }; + + let position = Point::new( + upem * raster_image.x as f64 / raster_image.pixels_per_em as f64, + upem * y_offset / raster_image.pixels_per_em as f64, + ); + let aspect_ratio = image.width() / image.height(); + let size = Size::new(upem, upem * aspect_ratio); + frame.push(position, FrameItem::Image(image, size, Span::detached())); + + Some(()) +} + +/// Draws a glyph from the COLR table into the frame. fn draw_colr_glyph( frame: &mut Frame, + font: &Font, upem: Abs, - ttf: &ttf_parser::Face, glyph_id: GlyphId, ) -> Option<()> { let mut svg = XmlWriter::new(xmlwriter::Options::default()); + let ttf = font.ttf(); let width = ttf.global_bounding_box().width() as f64; let height = ttf.global_bounding_box().height() as f64; let x_min = ttf.global_bounding_box().x_min as f64; @@ -87,8 +174,7 @@ fn draw_colr_glyph( transforms_stack: vec![ttf_parser::Transform::default()], }; - ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter) - .unwrap(); + ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter)?; svg.end_element(); let data = svg.end_document().into_bytes(); @@ -98,54 +184,22 @@ fn draw_colr_glyph( typst::visualize::ImageFormat::Vector(typst::visualize::VectorFormat::Svg), None, ) - .unwrap(); + .ok()?; let y_shift = Abs::raw(upem.to_raw() - y_max); let position = Point::new(Abs::raw(x_min), y_shift); - let size = Axes::new(Abs::pt(width), Abs::pt(height)); + let size = Size::new(Abs::pt(width), Abs::pt(height)); frame.push(position, FrameItem::Image(image, size, Span::detached())); Some(()) } -/// Draws a raster glyph in a frame. -fn draw_raster_glyph( - frame: &mut Frame, - font: &Font, - upem: Abs, - raster_image: ttf_parser::RasterGlyphImage, -) { - let image = Image::new( - raster_image.data.into(), - typst::visualize::ImageFormat::Raster(typst::visualize::RasterFormat::Png), - None, - ) - .unwrap(); - - // Apple Color emoji doesn't provide offset information (or at least - // not in a way ttf-parser understands), so we artificially shift their - // baseline to make it look good. - let y_offset = if font.info().family.to_lowercase() == "apple color emoji" { - 20.0 - } else { - -(raster_image.y as f64) - }; - - let position = Point::new( - upem * raster_image.x as f64 / raster_image.pixels_per_em as f64, - upem * y_offset / raster_image.pixels_per_em as f64, - ); - let aspect_ratio = image.width() / image.height(); - let size = Axes::new(upem, upem * aspect_ratio); - frame.push(position, FrameItem::Image(image, size, Span::detached())); -} - /// Draws an SVG glyph in a frame. fn draw_svg_glyph( frame: &mut Frame, - upem: Abs, font: &Font, + upem: Abs, glyph_id: GlyphId, ) -> Option<()> { // TODO: Our current conversion of the SVG table works for Twitter Color Emoji, @@ -211,9 +265,10 @@ fn draw_svg_glyph( typst::visualize::ImageFormat::Vector(typst::visualize::VectorFormat::Svg), None, ) - .unwrap(); + .ok()?; + let position = Point::new(Abs::pt(left), Abs::pt(top) + upem); - let size = Axes::new(Abs::pt(width), Abs::pt(height)); + let size = Size::new(Abs::pt(width), Abs::pt(height)); frame.push(position, FrameItem::Image(image, size, Span::detached())); Some(())