More robust glyph drawing (#5159)

This commit is contained in:
Laurenz 2024-10-10 13:59:00 +02:00 committed by GitHub
parent 9ee80762a5
commit 6257e4d6cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 205 additions and 125 deletions

View File

@ -12,10 +12,11 @@ use indexmap::IndexMap;
use pdf_writer::types::UnicodeCmap; use pdf_writer::types::UnicodeCmap;
use pdf_writer::writers::WMode; use pdf_writer::writers::WMode;
use pdf_writer::{Filter, Finish, Name, Rect, Ref}; 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::layout::Em;
use typst::text::color::frame_for_glyph; use typst::text::color::glyph_frame;
use typst::text::Font; use typst::text::{Font, Glyph, TextItemView};
use crate::content; use crate::content;
use crate::font::{base_font_name, write_font_descriptor, CMAP_NAME, SYSTEM_INFO}; use crate::font::{base_font_name, write_font_descriptor, CMAP_NAME, SYSTEM_INFO};
@ -211,9 +212,10 @@ impl ColorFontMap<()> {
pub fn get( pub fn get(
&mut self, &mut self,
options: &PdfOptions, options: &PdfOptions,
font: &Font, text: &TextItemView,
gid: u16, glyph: &Glyph,
) -> SourceResult<(usize, u8)> { ) -> SourceResult<(usize, u8)> {
let font = &text.item.font;
let color_font = self.map.entry(font.clone()).or_insert_with(|| { let color_font = self.map.entry(font.clone()).or_insert_with(|| {
let global_bbox = font.ttf().global_bounding_box(); let global_bbox = font.ttf().global_bounding_box();
let bbox = Rect::new( 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. // If we already know this glyph, return it.
(color_font.slice_ids[index_of_glyph / 256], *index_of_glyph as u8) (color_font.slice_ids[index_of_glyph / 256], *index_of_glyph as u8)
} else { } else {
@ -242,9 +244,13 @@ impl ColorFontMap<()> {
self.total_slice_count += 1; self.total_slice_count += 1;
} }
let frame = frame_for_glyph(font, gid); let (frame, tofu) = glyph_frame(font, glyph.id);
let width = if options.standards.pdfa && tofu {
font.advance(gid).unwrap_or(Em::new(0.0)).get() * font.units_per_em(); 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( let instructions = content::build(
options, options,
&mut self.resources, &mut self.resources,
@ -252,8 +258,8 @@ impl ColorFontMap<()> {
None, None,
Some(width as f32), Some(width as f32),
)?; )?;
color_font.glyphs.push(ColorGlyph { gid, instructions }); color_font.glyphs.push(ColorGlyph { gid: glyph.id, instructions });
color_font.glyph_indices.insert(gid, index); color_font.glyph_indices.insert(glyph.id, index);
(color_font.slice_ids[index / 256], index as u8) (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. /// represent the subset of the TTF font we are interested in.
pub subfont: usize, 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
}

View File

@ -10,15 +10,15 @@ use pdf_writer::types::{
}; };
use pdf_writer::writers::PositionedItems; use pdf_writer::writers::PositionedItems;
use pdf_writer::{Content, Finish, Name, Rect, Str}; 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::foundations::Repr;
use typst::layout::{ use typst::layout::{
Abs, Em, Frame, FrameItem, GroupItem, Point, Ratio, Size, Transform, Abs, Em, Frame, FrameItem, GroupItem, Point, Ratio, Size, Transform,
}; };
use typst::model::Destination; use typst::model::Destination;
use typst::syntax::Span; use typst::syntax::Span;
use typst::text::color::is_color_glyph; use typst::text::color::should_outline;
use typst::text::{Font, TextItem, TextItemView}; use typst::text::{Font, Glyph, TextItem, TextItemView};
use typst::utils::{Deferred, Numeric, SliceExt}; use typst::utils::{Deferred, Numeric, SliceExt};
use typst::visualize::{ use typst::visualize::{
FillRule, FixedStroke, Geometry, Image, LineCap, LineJoin, Paint, Path, PathItem, 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. /// Encode a text run into the content stream.
fn write_text(ctx: &mut Builder, pos: Point, text: &TextItem) -> SourceResult<()> { fn write_text(ctx: &mut Builder, pos: Point, text: &TextItem) -> SourceResult<()> {
if ctx.options.standards.pdfa { if ctx.options.standards.pdfa && text.font.info().is_last_resort() {
let last_resort = text.font.info().is_last_resort();
for g in &text.glyphs {
if last_resort || g.id == 0 {
bail!( bail!(
g.span.0, Span::find(text.glyphs.iter().map(|g| g.span.0)),
"the text {} could not be displayed with any font", "the text {} could not be displayed with any font",
TextItemView::full(text).glyph_text(g).repr(), &text.text,
); );
} }
}
}
let ttf = text.font.ttf(); let outline_glyphs =
let tables = ttf.tables(); text.glyphs.iter().filter(|g| should_outline(&text.font, g)).count();
// If the text run contains either only color glyphs (used for emojis for if outline_glyphs == text.glyphs.len() {
// 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 {
write_normal_text(ctx, pos, TextItemView::full(text))?; write_normal_text(ctx, pos, TextItemView::full(text))?;
} else if outline_glyphs == 0 {
write_complex_glyphs(ctx, pos, TextItemView::full(text))?;
} else { } 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 offset = 0;
let mut position_in_run = Abs::zero(); let mut position_in_run = Abs::zero();
for (color, sub_run) in for (should_outline, sub_run) in
text.glyphs.group_by_key(|g| is_color_glyph(&text.font, g)) text.glyphs.group_by_key(|g| should_outline(&text.font, g))
{ {
let end = offset + sub_run.len(); 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()); let pos = pos + Point::new(position_in_run, Abs::zero());
position_in_run += text_item_view.width(); position_in_run += text_item_view.width();
offset = end; offset = end;
// Actually write the sub text-run
if color { // Actually write the sub text-run.
write_color_glyphs(ctx, pos, text_item_view)?; if should_outline {
} else {
write_normal_text(ctx, pos, text_item_view)?; 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. // Write the glyphs with kerning adjustments.
for glyph in text.glyphs() { for glyph in text.glyphs() {
if ctx.options.standards.pdfa && glyph.id == 0 {
bail!(tofu(&text, glyph));
}
adjustment += glyph.x_offset; adjustment += glyph.x_offset;
if !adjustment.is_zero() { 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 /// Encodes a text run made only of color glyphs into the content stream
fn write_color_glyphs( fn write_complex_glyphs(
ctx: &mut Builder, ctx: &mut Builder,
pos: Point, pos: Point,
text: TextItemView, text: TextItemView,
@ -621,12 +607,17 @@ fn write_color_glyphs(
.or_default(); .or_default();
for glyph in text.glyphs() { 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. // Retrieve the Type3 font reference and the glyph index in the font.
let color_fonts = ctx let color_fonts = ctx
.resources .resources
.color_fonts .color_fonts
.get_or_insert_with(|| Box::new(ColorFontMap::new())); .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) { if last_font != Some(font) {
ctx.content.set_font( ctx.content.set_font(
@ -824,3 +815,13 @@ fn to_pdf_line_join(join: LineJoin) -> LineJoinStyle {
LineJoin::Bevel => LineJoinStyle::BevelJoin, 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(),
)
}

View File

@ -4,7 +4,7 @@ use pixglyph::Bitmap;
use tiny_skia as sk; use tiny_skia as sk;
use ttf_parser::{GlyphId, OutlineBuilder}; use ttf_parser::{GlyphId, OutlineBuilder};
use typst::layout::{Abs, Axes, Point, Size}; 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::text::{Font, TextItem};
use typst::visualize::{FixedStroke, Paint}; 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 id = GlyphId(glyph.id);
let offset = x + glyph.x_offset.at(text.size).to_f32(); 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 upem = text.font.units_per_em();
let text_scale = Abs::raw(text.size.to_raw() / upem); let text_scale = Abs::raw(text.size.to_raw() / upem);
let state = state let state = state
.pre_translate(Point::new(Abs::raw(offset as _), -text.size)) .pre_translate(Point::new(Abs::raw(offset as _), -text.size))
.pre_scale(Axes::new(text_scale, text_scale)); .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); 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(); x += glyph.x_advance.at(text.size).to_f32();

View File

@ -92,6 +92,13 @@ impl Span {
} }
} }
/// Find the first non-detached span in the iterator.
pub fn find(iter: impl IntoIterator<Item = Self>) -> Self {
iter.into_iter()
.find(|span| !span.is_detached())
.unwrap_or(Span::detached())
}
/// Resolve a file location relative to this span's source. /// Resolve a file location relative to this span's source.
pub fn resolve_path(self, path: &str) -> Result<FileId, EcoString> { pub fn resolve_path(self, path: &str) -> Result<FileId, EcoString> {
let Some(file) = self.id() else { let Some(file) = self.id() else {

View File

@ -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. /// Finds the first non-detached span in the list.
fn select_span(children: &[Pair]) -> Span { fn select_span(children: &[Pair]) -> Span {
children Span::find(children.iter().map(|(c, _)| c.span()))
.iter()
.map(|(c, _)| c.span())
.find(|span| !span.is_detached())
.unwrap_or(Span::detached())
} }

View File

@ -6,51 +6,138 @@ use ttf_parser::{GlyphId, RgbaColor};
use usvg::tiny_skia_path; use usvg::tiny_skia_path;
use xmlwriter::XmlWriter; 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::syntax::Span;
use crate::text::{Font, Glyph}; 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. /// Whether this glyph should be rendered via simple outlining instead of via
pub fn is_color_glyph(font: &Font, g: &Glyph) -> bool { /// `glyph_frame`.
pub fn should_outline(font: &Font, glyph: &Glyph) -> bool {
let ttf = font.ttf(); let ttf = font.ttf();
let glyph_id = GlyphId(g.id); let glyph_id = GlyphId(glyph.id);
ttf.glyph_raster_image(glyph_id, 160).is_some() (ttf.tables().glyf.is_some() || ttf.tables().cff.is_some())
|| ttf.glyph_svg_image(glyph_id).is_some() && !ttf
|| ttf.is_color_glyph(glyph_id) .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 /// The glyphs are sized in font units, [`text.item.size`] is not taken into
/// account. /// account.
#[comemo::memoize] #[comemo::memoize]
pub fn frame_for_glyph(font: &Font, glyph_id: u16) -> Frame { pub fn glyph_frame(font: &Font, glyph_id: u16) -> (Frame, bool) {
let ttf = font.ttf(); let upem = Abs::pt(font.units_per_em());
let upem = Abs::pt(ttf.units_per_em() as f64);
let glyph_id = GlyphId(glyph_id); let glyph_id = GlyphId(glyph_id);
let mut frame = Frame::soft(Size::splat(upem)); 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) { if draw_glyph(&mut frame, font, upem, glyph_id).is_none()
draw_raster_glyph(&mut frame, font, upem, raster_image); && font.ttf().glyph_index(' ') != Some(glyph_id)
} else if ttf.is_color_glyph(glyph_id) { {
draw_colr_glyph(&mut frame, upem, ttf, glyph_id); // Generate a fallback tofu if the glyph couldn't be drawn, unless it is
} else if ttf.glyph_svg_image(glyph_id).is_some() { // the space glyph. Then, an empty frame does the job. (This happens for
draw_svg_glyph(&mut frame, upem, font, glyph_id); // 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( fn draw_colr_glyph(
frame: &mut Frame, frame: &mut Frame,
font: &Font,
upem: Abs, upem: Abs,
ttf: &ttf_parser::Face,
glyph_id: GlyphId, glyph_id: GlyphId,
) -> Option<()> { ) -> Option<()> {
let mut svg = XmlWriter::new(xmlwriter::Options::default()); let mut svg = XmlWriter::new(xmlwriter::Options::default());
let ttf = font.ttf();
let width = ttf.global_bounding_box().width() as f64; let width = ttf.global_bounding_box().width() as f64;
let height = ttf.global_bounding_box().height() as f64; let height = ttf.global_bounding_box().height() as f64;
let x_min = ttf.global_bounding_box().x_min 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()], transforms_stack: vec![ttf_parser::Transform::default()],
}; };
ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter) ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter)?;
.unwrap();
svg.end_element(); svg.end_element();
let data = svg.end_document().into_bytes(); let data = svg.end_document().into_bytes();
@ -98,54 +184,22 @@ fn draw_colr_glyph(
typst::visualize::ImageFormat::Vector(typst::visualize::VectorFormat::Svg), typst::visualize::ImageFormat::Vector(typst::visualize::VectorFormat::Svg),
None, None,
) )
.unwrap(); .ok()?;
let y_shift = Abs::raw(upem.to_raw() - y_max); let y_shift = Abs::raw(upem.to_raw() - y_max);
let position = Point::new(Abs::raw(x_min), y_shift); 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())); frame.push(position, FrameItem::Image(image, size, Span::detached()));
Some(()) 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. /// Draws an SVG glyph in a frame.
fn draw_svg_glyph( fn draw_svg_glyph(
frame: &mut Frame, frame: &mut Frame,
upem: Abs,
font: &Font, font: &Font,
upem: Abs,
glyph_id: GlyphId, glyph_id: GlyphId,
) -> Option<()> { ) -> Option<()> {
// TODO: Our current conversion of the SVG table works for Twitter Color Emoji, // 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), typst::visualize::ImageFormat::Vector(typst::visualize::VectorFormat::Svg),
None, None,
) )
.unwrap(); .ok()?;
let position = Point::new(Abs::pt(left), Abs::pt(top) + upem); 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())); frame.push(position, FrameItem::Image(image, size, Span::detached()));
Some(()) Some(())