use std::collections::BTreeMap;
use azul_core::{
geom::{LogicalRect, LogicalSize, LogicalPosition},
resources::DecodedImage,
};
use azul_css::props::basic::ColorU;
use azul_layout::{
solver3::display_list::{DisplayList, DisplayListItem},
text3::cache::{FontManager, ParsedFontTrait, UnifiedLayout},
};
use crate::{Color, Mm, Op, Pt, Rgb, FontId, RawImage, XObjectId, XObjectTransform,
ExtendedGraphicsState, ExtendedGraphicsStateId, XObject};
use crate::shading::{GradientStop, Shading, ShadingGeometry, ShadingId};
use azul_css::props::basic::{
color::ColorOrSystem,
geometry::{LayoutPoint, LayoutRect, LayoutSize},
};
pub type ResolvedImages = BTreeMap<String, (XObjectId, RawImage)>;
#[derive(Default)]
pub struct BridgeResources {
pub extgstates: Vec<(ExtendedGraphicsStateId, ExtendedGraphicsState)>,
pub shadings: Vec<(ShadingId, Shading)>,
pub xobjects: Vec<(XObjectId, XObject)>,
}
impl BridgeResources {
fn add_extgstate(&mut self, gs: ExtendedGraphicsState) -> ExtendedGraphicsStateId {
let id = ExtendedGraphicsStateId::new();
self.extgstates.push((id.clone(), gs));
id
}
fn add_shading(&mut self, shading: Shading) -> ShadingId {
let id = ShadingId::new();
self.shadings.push((id.clone(), shading));
id
}
pub fn register_into(self, resources: &mut crate::PdfResources) {
for (id, gs) in self.extgstates {
resources.extgstates.map.entry(id).or_insert(gs);
}
for (id, sh) in self.shadings {
resources.shadings.map.entry(id).or_insert(sh);
}
for (id, xobj) in self.xobjects {
resources.xobjects.map.entry(id).or_insert(xobj);
}
}
}
pub fn image_xobject_id(src_key: &str) -> XObjectId {
let sanitized: String = src_key
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
XObjectId(format!("HtmlImg_{}", sanitized))
}
pub fn resolve_html_images(images: &BTreeMap<String, Vec<u8>>) -> ResolvedImages {
let mut out = ResolvedImages::new();
for (key, bytes) in images.iter() {
let mut warnings = Vec::new();
if let Ok(raw) = RawImage::decode_from_bytes(bytes, &mut warnings) {
out.insert(key.clone(), (image_xobject_id(key), raw));
}
}
out
}
use super::border::{
BorderConfig, extract_border_widths, extract_border_colors,
extract_border_styles, extract_border_radii, render_border,
};
fn convert_color(color: &ColorU) -> Color {
Color::Rgb(Rgb {
r: color.r as f32 / 255.0,
g: color.g as f32 / 255.0,
b: color.b as f32 / 255.0,
icc_profile: None,
})
}
fn apply_fill_alpha(ops: &mut Vec<Op>, bridge_res: &mut BridgeResources, color: &ColorU) {
if color.a >= 255 {
return;
}
let alpha = color.a as f32 / 255.0;
let mut gs = ExtendedGraphicsState::default();
gs.set_current_fill_alpha(alpha);
gs.set_current_stroke_alpha(alpha);
let id = bridge_res.add_extgstate(gs);
ops.push(Op::LoadGraphicsState { gs: id });
}
fn make_clip_polygon(
transform: &CoordTransform,
bounds: &LogicalRect,
radii_px: [f32; 4],
page_height: f32,
margin_left: f32,
margin_top: f32,
) -> crate::graphics::Polygon {
let radii = crate::html::border::BorderRadii {
top_left: (radii_px[0] * CSS_PX_TO_PT, radii_px[0] * CSS_PX_TO_PT),
top_right: (radii_px[1] * CSS_PX_TO_PT, radii_px[1] * CSS_PX_TO_PT),
bottom_right: (radii_px[2] * CSS_PX_TO_PT, radii_px[2] * CSS_PX_TO_PT),
bottom_left: (radii_px[3] * CSS_PX_TO_PT, radii_px[3] * CSS_PX_TO_PT),
};
if radii_px.iter().any(|r| *r > 0.0) {
let b = bounds_px_to_pt(bounds);
let points = crate::html::border::create_rounded_rect_path_with_margins(
b.origin.x, b.origin.y, b.size.width, b.size.height,
&radii, page_height, margin_left, margin_top,
);
crate::graphics::Polygon {
rings: vec![crate::graphics::PolygonRing { points }],
mode: crate::graphics::PaintMode::Clip,
winding_order: crate::graphics::WindingOrder::NonZero,
}
} else {
let x = transform.x(bounds.origin.x);
let y = transform.rect_y(bounds.origin.y, bounds.size.height);
let w = transform.dim(bounds.size.width);
let h = transform.dim(bounds.size.height);
let mut p = make_rect_polygon_pt(x, y, w, h);
p.mode = crate::graphics::PaintMode::Clip;
p
}
}
fn resolve_stop_color(c: &ColorOrSystem) -> ColorU {
match c {
ColorOrSystem::Color(col) => *col,
ColorOrSystem::System(_) => ColorU { r: 0, g: 0, b: 0, a: 255 },
}
}
fn normalize_gradient_stops(
stops: &azul_css::props::style::background::NormalizedLinearColorStopVec,
) -> Vec<GradientStop> {
stops
.iter()
.map(|s| {
let c = resolve_stop_color(&s.color);
GradientStop {
offset: s.offset.normalized().clamp(0.0, 1.0),
color: [c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0],
}
})
.collect()
}
pub fn display_list_to_printpdf_ops_with_margins<T: ParsedFontTrait + 'static>(
display_list: &DisplayList,
page_size: LogicalSize,
margin_left_pt: f32,
margin_top_pt: f32,
font_manager: &FontManager<T>,
images: &ResolvedImages,
bridge_res: &mut BridgeResources,
) -> Result<Vec<Op>, String> {
let mut ops = Vec::new();
let page_height = page_size.height;
let transform = CoordTransform::new(page_height, margin_left_pt, margin_top_pt);
let mut current_text_layout: Option<(&azul_layout::text3::cache::UnifiedLayout, LogicalRect)> = None;
let mut pending_text: Option<(&azul_layout::text3::cache::UnifiedLayout, LogicalRect, ColorU)> = None;
for item in display_list.items.iter() {
if matches!(
item,
DisplayListItem::TextLayout { .. }
| DisplayListItem::Text { .. }
| DisplayListItem::PushClip { .. }
| DisplayListItem::PopClip
| DisplayListItem::PushOpacity { .. }
| DisplayListItem::PopOpacity
) {
if let Some((layout, bounds, color)) = pending_text.take() {
render_unified_layout_with_margins(
&mut ops, layout, &bounds, color, &transform, font_manager,
);
}
}
if let DisplayListItem::TextLayout { layout, bounds, color, .. } = item {
if let Some(unified_layout) =
layout.downcast_ref::<azul_layout::text3::cache::UnifiedLayout>()
{
pending_text = Some((unified_layout, *bounds.inner(), *color));
current_text_layout = Some((unified_layout, *bounds.inner()));
}
continue;
}
convert_display_list_item_with_margins(
&mut ops,
item,
page_height,
margin_left_pt,
margin_top_pt,
&mut current_text_layout,
font_manager,
images,
bridge_res,
);
}
if let Some((layout, bounds, color)) = pending_text.take() {
render_unified_layout_with_margins(&mut ops, layout, &bounds, color, &transform, font_manager);
}
Ok(ops)
}
pub fn display_list_to_printpdf_ops<T: ParsedFontTrait + 'static>(
display_list: &DisplayList,
page_size: LogicalSize,
font_manager: &FontManager<T>,
) -> Result<Vec<Op>, String> {
let mut bridge_res = BridgeResources::default();
display_list_to_printpdf_ops_with_margins(display_list, page_size, 0.0, 0.0, font_manager, &ResolvedImages::new(), &mut bridge_res)
}
const CSS_PX_TO_PT: f32 = 72.0 / 96.0;
#[inline]
fn pt_to_mm(pt: f32) -> Mm {
Mm::from(Pt(pt))
}
fn bounds_px_to_pt(b: &LogicalRect) -> LogicalRect {
LogicalRect {
origin: LogicalPosition::new(
b.origin.x * CSS_PX_TO_PT,
b.origin.y * CSS_PX_TO_PT,
),
size: LogicalSize::new(
b.size.width * CSS_PX_TO_PT,
b.size.height * CSS_PX_TO_PT,
),
}
}
fn make_rect_polygon_pt(x: f32, y: f32, w: f32, h: f32) -> crate::graphics::Polygon {
let lp = |px: f32, py: f32| crate::graphics::LinePoint {
p: crate::graphics::Point::new(pt_to_mm(px), pt_to_mm(py)),
bezier: false,
};
crate::graphics::Polygon {
rings: vec![crate::graphics::PolygonRing {
points: vec![
lp(x, y),
lp(x + w, y),
lp(x + w, y + h),
lp(x, y + h),
],
}],
mode: crate::graphics::PaintMode::Fill,
winding_order: crate::graphics::WindingOrder::NonZero,
}
}
#[derive(Clone, Copy)]
struct CoordTransform {
page_height: f32,
margin_left: f32,
margin_top: f32,
}
impl CoordTransform {
fn new(page_height: f32, margin_left: f32, margin_top: f32) -> Self {
Self { page_height, margin_left, margin_top }
}
#[inline]
fn x(&self, layout_x: f32) -> f32 {
layout_x * CSS_PX_TO_PT + self.margin_left
}
#[inline]
fn y(&self, layout_y: f32) -> f32 {
self.page_height - layout_y * CSS_PX_TO_PT - self.margin_top
}
#[inline]
fn rect_y(&self, layout_y: f32, height: f32) -> f32 {
self.page_height - (layout_y + height) * CSS_PX_TO_PT - self.margin_top
}
#[inline]
fn dim(&self, px_value: f32) -> f32 {
px_value * CSS_PX_TO_PT
}
}
fn convert_display_list_item_with_margins<'a, T: ParsedFontTrait + 'static>(
ops: &mut Vec<Op>,
item: &'a DisplayListItem,
page_height: f32,
margin_left: f32,
margin_top: f32,
current_text_layout: &mut Option<(&'a UnifiedLayout, LogicalRect)>,
_font_manager: &FontManager<T>,
images: &ResolvedImages,
bridge_res: &mut BridgeResources,
) {
let transform = CoordTransform::new(page_height, margin_left, margin_top);
match item {
DisplayListItem::Rect {
bounds,
color,
border_radius,
} => {
if bounds.size().width == 0.0 || bounds.size().height == 0.0 {
return;
}
ops.push(Op::SaveGraphicsState);
apply_fill_alpha(ops, bridge_res, color);
let radii = crate::html::border::BorderRadii {
top_left: (border_radius.top_left * CSS_PX_TO_PT, border_radius.top_left * CSS_PX_TO_PT),
top_right: (border_radius.top_right * CSS_PX_TO_PT, border_radius.top_right * CSS_PX_TO_PT),
bottom_right: (border_radius.bottom_right * CSS_PX_TO_PT, border_radius.bottom_right * CSS_PX_TO_PT),
bottom_left: (border_radius.bottom_left * CSS_PX_TO_PT, border_radius.bottom_left * CSS_PX_TO_PT),
};
let has_radius = radii.top_left.0 > 0.0 || radii.top_left.1 > 0.0
|| radii.top_right.0 > 0.0 || radii.top_right.1 > 0.0
|| radii.bottom_right.0 > 0.0 || radii.bottom_right.1 > 0.0
|| radii.bottom_left.0 > 0.0 || radii.bottom_left.1 > 0.0;
if has_radius {
let b = bounds_px_to_pt(bounds.inner());
let points = crate::html::border::create_rounded_rect_path_with_margins(
b.origin.x,
b.origin.y,
b.size.width,
b.size.height,
&radii,
page_height,
margin_left,
margin_top,
);
let polygon = crate::graphics::Polygon {
rings: vec![crate::graphics::PolygonRing { points }],
mode: crate::graphics::PaintMode::Fill,
winding_order: crate::graphics::WindingOrder::NonZero,
};
ops.push(Op::SetFillColor {
col: convert_color(color),
});
ops.push(Op::DrawPolygon { polygon });
} else {
let x = transform.x(bounds.origin().x);
let y = transform.rect_y(bounds.origin().y, bounds.size().height);
let w = transform.dim(bounds.size().width);
let h = transform.dim(bounds.size().height);
ops.push(Op::SetFillColor {
col: convert_color(color),
});
ops.push(Op::DrawPolygon { polygon: make_rect_polygon_pt(x, y, w, h) });
}
ops.push(Op::RestoreGraphicsState);
}
DisplayListItem::TextLayout {
layout,
bounds,
font_hash: _,
font_size_px: _,
color: _,
} => {
if let Some(unified_layout) = layout.downcast_ref::<azul_layout::text3::cache::UnifiedLayout>() {
*current_text_layout = Some((unified_layout, *bounds.inner()));
}
}
DisplayListItem::Text { glyphs, font_hash, font_size_px, color, clip_rect: _, source_node_index: _ } => {
if glyphs.is_empty() {
return;
}
let use_builtin_font = font_hash.font_hash == 0;
if !use_builtin_font {
return;
}
ops.push(Op::SaveGraphicsState);
ops.push(Op::SetFillColor {
col: convert_color(color),
});
if use_builtin_font {
ops.push(Op::SetFont {
font: crate::ops::PdfFontHandle::Builtin(crate::BuiltinFont::Helvetica),
size: Pt(transform.dim(*font_size_px)),
});
} else {
let font_id = FontId(format!("F{}", font_hash.font_hash));
ops.push(Op::SetFont {
font: crate::ops::PdfFontHandle::External(font_id),
size: Pt(transform.dim(*font_size_px)),
});
}
ops.push(Op::StartTextSection);
let mut text_string = String::new();
let mut first_glyph_pos: Option<(f32, f32)> = None;
for glyph in glyphs {
if first_glyph_pos.is_none() {
first_glyph_pos = Some((glyph.point.x, glyph.point.y));
}
if use_builtin_font {
if let Some(ch) = char::from_u32(glyph.index) {
text_string.push(ch);
}
}
}
if let Some((x, y)) = first_glyph_pos {
let pdf_x = transform.x(x);
let pdf_y = transform.y(y);
ops.push(Op::SetTextMatrix {
matrix: crate::matrix::TextMatrix::Raw([
1.0, 0.0, 0.0, 1.0,
pdf_x, pdf_y,
]),
});
if use_builtin_font && !text_string.is_empty() {
ops.push(Op::ShowText {
items: vec![crate::text::TextItem::Text(text_string)],
});
} else if !use_builtin_font {
let glyph_ids: Vec<crate::text::Codepoint> = glyphs.iter().map(|g| {
crate::text::Codepoint::new(g.index as u16, 0.0)
}).collect();
ops.push(Op::ShowText {
items: vec![crate::text::TextItem::GlyphIds(glyph_ids)],
});
}
}
ops.push(Op::EndTextSection);
ops.push(Op::RestoreGraphicsState);
}
DisplayListItem::Border {
bounds,
widths,
colors,
styles,
border_radius,
} => {
let bounds_pt = bounds_px_to_pt(bounds.inner());
let config = BorderConfig {
bounds: bounds_pt,
widths: extract_border_widths(widths),
colors: extract_border_colors(colors),
styles: extract_border_styles(styles),
radii: extract_border_radii(border_radius),
page_height,
margin_left,
margin_top,
};
render_border(ops, &config);
}
DisplayListItem::Image { bounds, image, border_radius: _ } => {
let src_key = match image.get_data() {
DecodedImage::NullImage { tag, .. } if !tag.is_empty() => {
String::from_utf8(tag.clone()).ok()
}
_ => None,
};
let Some(src_key) = src_key else { return; };
let Some((xobject_id, raw_image)) = images.get(&src_key) else { return; };
let b = bounds_px_to_pt(bounds.inner());
let target_w_pt = b.size.width;
let target_h_pt = b.size.height;
if target_w_pt <= 0.0 || target_h_pt <= 0.0 {
return;
}
const IMAGE_DPI: f32 = 300.0;
let natural_w_pt = crate::Px(raw_image.width).into_pt(IMAGE_DPI).0;
let natural_h_pt = crate::Px(raw_image.height).into_pt(IMAGE_DPI).0;
if natural_w_pt <= 0.0 || natural_h_pt <= 0.0 {
return;
}
let scale_x = target_w_pt / natural_w_pt;
let scale_y = target_h_pt / natural_h_pt;
let pdf_x = transform.x(bounds.origin().x);
let pdf_y = transform.rect_y(bounds.origin().y, bounds.size().height);
ops.push(Op::UseXobject {
id: xobject_id.clone(),
transform: XObjectTransform {
translate_x: Some(Pt(pdf_x)),
translate_y: Some(Pt(pdf_y)),
rotate: None,
scale_x: Some(scale_x),
scale_y: Some(scale_y),
dpi: Some(IMAGE_DPI),
no_auto_scale: false,
},
});
}
DisplayListItem::Underline { bounds, color, thickness: _ }
| DisplayListItem::Strikethrough { bounds, color, thickness: _ }
| DisplayListItem::Overline { bounds, color, thickness: _ } => {
if bounds.size().width > 0.0 && bounds.size().height > 0.0 {
let x = transform.x(bounds.origin().x);
let y = transform.rect_y(bounds.origin().y, bounds.size().height);
let w = transform.dim(bounds.size().width);
let h = transform.dim(bounds.size().height);
ops.push(Op::SaveGraphicsState);
apply_fill_alpha(ops, bridge_res, color);
ops.push(Op::SetFillColor { col: convert_color(color) });
ops.push(Op::DrawPolygon { polygon: make_rect_polygon_pt(x, y, w, h) });
ops.push(Op::RestoreGraphicsState);
}
}
DisplayListItem::LinearGradient { bounds, gradient, border_radius } => {
let inner = bounds.inner();
if inner.size.width <= 0.0 || inner.size.height <= 0.0 {
return;
}
let rect = LayoutRect::new(
LayoutPoint::new(0, 0),
LayoutSize::new(inner.size.width as isize, inner.size.height as isize),
);
let (p0, p1) = gradient.direction.to_points(&rect);
let coords = [
transform.x(inner.origin.x + p0.x as f32),
transform.y(inner.origin.y + p0.y as f32),
transform.x(inner.origin.x + p1.x as f32),
transform.y(inner.origin.y + p1.y as f32),
];
let stops = normalize_gradient_stops(&gradient.stops);
if stops.is_empty() {
return;
}
let id = bridge_res.add_shading(Shading {
geometry: ShadingGeometry::Axial { coords },
stops,
extend: (true, true),
});
ops.push(Op::SaveGraphicsState);
ops.push(Op::DrawPolygon {
polygon: make_clip_polygon(
&transform, inner,
[border_radius.top_left, border_radius.top_right,
border_radius.bottom_right, border_radius.bottom_left],
page_height, margin_left, margin_top,
),
});
ops.push(Op::PaintShading { id });
ops.push(Op::RestoreGraphicsState);
}
DisplayListItem::RadialGradient { bounds, gradient, border_radius } => {
let inner = bounds.inner();
if inner.size.width <= 0.0 || inner.size.height <= 0.0 {
return;
}
let cx = inner.size.width / 2.0;
let cy = inner.size.height / 2.0;
let r_px = cx.max(inner.size.width - cx).hypot(cy.max(inner.size.height - cy));
let center_x = transform.x(inner.origin.x + cx);
let center_y = transform.y(inner.origin.y + cy);
let r_pt = r_px * CSS_PX_TO_PT;
let coords = [center_x, center_y, 0.0, center_x, center_y, r_pt];
let stops = normalize_gradient_stops(&gradient.stops);
if stops.is_empty() {
return;
}
let id = bridge_res.add_shading(Shading {
geometry: ShadingGeometry::Radial { coords },
stops,
extend: (true, true),
});
ops.push(Op::SaveGraphicsState);
ops.push(Op::DrawPolygon {
polygon: make_clip_polygon(
&transform, inner,
[border_radius.top_left, border_radius.top_right,
border_radius.bottom_right, border_radius.bottom_left],
page_height, margin_left, margin_top,
),
});
ops.push(Op::PaintShading { id });
ops.push(Op::RestoreGraphicsState);
}
DisplayListItem::PushOpacity { opacity, .. } => {
ops.push(Op::SaveGraphicsState);
let a = (*opacity).clamp(0.0, 1.0);
if a < 1.0 {
let mut gs = ExtendedGraphicsState::default();
gs.set_current_fill_alpha(a);
gs.set_current_stroke_alpha(a);
let id = bridge_res.add_extgstate(gs);
ops.push(Op::LoadGraphicsState { gs: id });
}
}
DisplayListItem::PopOpacity => {
ops.push(Op::RestoreGraphicsState);
}
DisplayListItem::PushClip { bounds, border_radius } => {
ops.push(Op::SaveGraphicsState);
let radii = crate::html::border::BorderRadii {
top_left: (border_radius.top_left * CSS_PX_TO_PT, border_radius.top_left * CSS_PX_TO_PT),
top_right: (border_radius.top_right * CSS_PX_TO_PT, border_radius.top_right * CSS_PX_TO_PT),
bottom_right: (border_radius.bottom_right * CSS_PX_TO_PT, border_radius.bottom_right * CSS_PX_TO_PT),
bottom_left: (border_radius.bottom_left * CSS_PX_TO_PT, border_radius.bottom_left * CSS_PX_TO_PT),
};
let has_radius = radii.top_left.0 > 0.0 || radii.top_right.0 > 0.0
|| radii.bottom_right.0 > 0.0 || radii.bottom_left.0 > 0.0;
let polygon = if has_radius {
let b = bounds_px_to_pt(bounds.inner());
let points = crate::html::border::create_rounded_rect_path_with_margins(
b.origin.x, b.origin.y, b.size.width, b.size.height,
&radii, page_height, margin_left, margin_top,
);
crate::graphics::Polygon {
rings: vec![crate::graphics::PolygonRing { points }],
mode: crate::graphics::PaintMode::Clip,
winding_order: crate::graphics::WindingOrder::NonZero,
}
} else {
let x = transform.x(bounds.origin().x);
let y = transform.rect_y(bounds.origin().y, bounds.size().height);
let w = transform.dim(bounds.size().width);
let h = transform.dim(bounds.size().height);
let mut p = make_rect_polygon_pt(x, y, w, h);
p.mode = crate::graphics::PaintMode::Clip;
p
};
ops.push(Op::DrawPolygon { polygon });
}
DisplayListItem::PopClip => {
ops.push(Op::RestoreGraphicsState);
}
_ => {
}
}
}
pub fn render_unified_layout_public<T: ParsedFontTrait + 'static>(
layout: &UnifiedLayout,
bounds_width: f32,
bounds_height: f32,
color: ColorU,
page_height: f32,
_font_manager: &FontManager<T>,
) -> Vec<Op> {
let mut ops = Vec::new();
let bounds = LogicalRect {
origin: LogicalPosition::new(0.0, 0.0),
size: LogicalSize::new(bounds_width, bounds_height),
};
render_unified_layout_impl(&mut ops, layout, &bounds, color, page_height, _font_manager);
ops
}
fn render_unified_layout_impl<T: ParsedFontTrait + 'static>(
ops: &mut Vec<Op>,
layout: &UnifiedLayout,
bounds: &LogicalRect,
_color: ColorU, page_height: f32,
font_manager: &FontManager<T>,
) {
use azul_layout::text3::glyphs::get_glyph_runs_pdf;
let loaded_fonts = font_manager.get_loaded_fonts();
let glyph_runs = get_glyph_runs_pdf(layout, &loaded_fonts);
if glyph_runs.is_empty() {
return;
}
let mut current_color: Option<ColorU> = None;
for run in glyph_runs.iter() {
if run.glyphs.is_empty() {
continue;
}
if current_color != Some(run.color) {
ops.push(Op::SetFillColor {
col: convert_color(&run.color),
});
current_color = Some(run.color);
}
let font_id = FontId(format!("F{}", run.font_hash));
ops.push(Op::SetFont {
font: crate::ops::PdfFontHandle::External(font_id.clone()),
size: Pt(run.font_size_px * CSS_PX_TO_PT),
});
ops.push(Op::StartTextSection);
for glyph in &run.glyphs {
let glyph_x_px = bounds.origin.x + glyph.position.x;
let glyph_y_px = bounds.origin.y + glyph.position.y;
let pdf_x = glyph_x_px * CSS_PX_TO_PT;
let pdf_y = page_height - glyph_y_px * CSS_PX_TO_PT;
ops.push(Op::SetTextMatrix {
matrix: crate::matrix::TextMatrix::Raw([
1.0, 0.0, 0.0, 1.0, pdf_x, pdf_y, ]),
});
ops.push(Op::ShowText {
items: vec![crate::text::TextItem::GlyphIds(vec![
crate::text::Codepoint {
gid: glyph.glyph_id,
offset: 0.0,
cid: Some(glyph.unicode_codepoint.clone()),
}
])],
});
}
ops.push(Op::EndTextSection);
}
}
fn actualtext_span_begin(text: &str) -> Op {
let mut data = vec![0xFE, 0xFF]; for u in text.encode_utf16() {
data.push((u >> 8) as u8);
data.push((u & 0xFF) as u8);
}
let mut map = std::collections::BTreeMap::new();
map.insert(
"ActualText".to_string(),
crate::DictItem::String { data, literal: false },
);
Op::BeginMarkedContentWithProperties {
tag: "Span".to_string(),
properties: crate::DictItem::Dict { map },
}
}
fn render_unified_layout_with_margins<T: ParsedFontTrait + 'static>(
ops: &mut Vec<Op>,
layout: &UnifiedLayout,
bounds: &LogicalRect,
_color: ColorU, transform: &CoordTransform,
font_manager: &FontManager<T>,
) {
use azul_layout::text3::glyphs::get_glyph_runs_pdf;
let loaded_fonts = font_manager.get_loaded_fonts();
let glyph_runs = get_glyph_runs_pdf(layout, &loaded_fonts);
if glyph_runs.is_empty() {
return;
}
for run in glyph_runs.iter() {
if run.glyphs.is_empty() {
continue;
}
if let Some(bg_color) = run.background_color {
if bg_color.a > 0 {
if let (Some(first_glyph), Some(last_glyph)) =
(run.glyphs.first(), run.glyphs.last())
{
let font_size = run.font_size_px;
let ascent = font_size * 0.8;
let descent = font_size * 0.2;
let bg_start_x = bounds.origin.x + first_glyph.position.x;
let bg_end_x = bounds.origin.x + last_glyph.position.x + last_glyph.advance;
let bg_width = bg_end_x - bg_start_x;
let baseline_y = bounds.origin.y + first_glyph.position.y;
let bg_top_y = baseline_y - ascent;
let bg_height = ascent + descent;
let pdf_x = transform.x(bg_start_x);
let pdf_y = transform.rect_y(bg_top_y, bg_height);
let pdf_w = transform.dim(bg_width);
let pdf_h = transform.dim(bg_height);
ops.push(Op::SaveGraphicsState);
ops.push(Op::SetFillColor {
col: convert_color(&bg_color),
});
ops.push(Op::DrawPolygon { polygon: make_rect_polygon_pt(pdf_x, pdf_y, pdf_w, pdf_h) });
ops.push(Op::RestoreGraphicsState);
}
}
}
}
let mut current_color: Option<ColorU> = None;
for run in glyph_runs.iter() {
if run.glyphs.is_empty() {
continue;
}
if current_color != Some(run.color) {
ops.push(Op::SetFillColor {
col: convert_color(&run.color),
});
current_color = Some(run.color);
}
let font_id = FontId(format!("F{}", run.font_hash));
ops.push(Op::SetFont {
font: crate::ops::PdfFontHandle::External(font_id.clone()),
size: Pt(transform.dim(run.font_size_px)),
});
ops.push(Op::StartTextSection);
let run_text: String = run.glyphs.iter().map(|g| g.unicode_codepoint.as_str()).collect();
let wrapped = !run_text.is_empty();
if wrapped {
ops.push(actualtext_span_begin(&run_text));
}
for glyph in &run.glyphs {
let glyph_x_layout = bounds.origin.x + glyph.position.x;
let glyph_y_layout = bounds.origin.y + glyph.position.y;
let pdf_x = transform.x(glyph_x_layout);
let pdf_y = transform.y(glyph_y_layout);
ops.push(Op::SetTextMatrix {
matrix: crate::matrix::TextMatrix::Raw([
1.0, 0.0, 0.0, 1.0, pdf_x, pdf_y, ]),
});
ops.push(Op::ShowText {
items: vec![crate::text::TextItem::GlyphIds(vec![
crate::text::Codepoint {
gid: glyph.glyph_id,
offset: 0.0,
cid: Some(glyph.unicode_codepoint.clone()),
}
])],
});
}
if wrapped {
ops.push(Op::EndMarkedContent);
}
ops.push(Op::EndTextSection);
}
}
pub fn apply_margin_offset(ops: &mut [Op], offset_x: crate::Mm, offset_y: crate::Mm) {
if offset_x.0 == 0.0 && offset_y.0 == 0.0 {
return;
}
let offset_x_pt = crate::Pt(offset_x.0 * 2.83465);
let offset_y_pt = crate::Pt(offset_y.0 * 2.83465);
for op in ops.iter_mut() {
match op {
Op::DrawPolygon { polygon } => {
for ring in &mut polygon.rings {
for point in &mut ring.points {
point.p.x = crate::Pt(point.p.x.0 + offset_x_pt.0);
point.p.y = crate::Pt(point.p.y.0 + offset_y_pt.0);
}
}
}
Op::DrawLine { line } => {
for point in &mut line.points {
point.p.x = crate::Pt(point.p.x.0 + offset_x_pt.0);
point.p.y = crate::Pt(point.p.y.0 + offset_y_pt.0);
}
}
Op::SetTextCursor { pos } => {
pos.x = crate::Pt(pos.x.0 + offset_x_pt.0);
pos.y = crate::Pt(pos.y.0 + offset_y_pt.0);
}
Op::UseXobject { transform, .. } => {
transform.translate_x = Some(crate::Pt(
transform.translate_x.unwrap_or(crate::Pt(0.0)).0 + offset_x_pt.0
));
transform.translate_y = Some(crate::Pt(
transform.translate_y.unwrap_or(crate::Pt(0.0)).0 + offset_y_pt.0
));
}
Op::DrawRectangle { rectangle } => {
rectangle.x = crate::Pt(rectangle.x.0 + offset_x_pt.0);
rectangle.y = crate::Pt(rectangle.y.0 + offset_y_pt.0);
}
Op::LinkAnnotation { link } => {
link.rect.x = crate::Pt(link.rect.x.0 + offset_x_pt.0);
link.rect.y = crate::Pt(link.rect.y.0 + offset_y_pt.0);
}
Op::Marker { .. }
| Op::SetColorSpaceStroke { .. }
| Op::SetColorSpaceFill { .. }
| Op::BeginLayer { .. }
| Op::EndLayer
| Op::SaveGraphicsState
| Op::RestoreGraphicsState
| Op::LoadGraphicsState { .. }
| Op::PaintShading { .. }
| Op::StartTextSection
| Op::EndTextSection
| Op::SetFont { .. }
| Op::ShowText { .. }
| Op::AddLineBreak
| Op::SetLineHeight { .. }
| Op::SetWordSpacing { .. }
| Op::SetFillColor { .. }
| Op::SetOutlineColor { .. }
| Op::SetOutlineThickness { .. }
| Op::SetLineDashPattern { .. }
| Op::SetLineJoinStyle { .. }
| Op::SetLineCapStyle { .. }
| Op::SetMiterLimit { .. }
| Op::SetTextRenderingMode { .. }
| Op::SetCharacterSpacing { .. }
| Op::SetLineOffset { .. }
| Op::SetTransformationMatrix { .. }
| Op::SetTextMatrix { .. }
| Op::MoveTextCursorAndSetLeading { .. }
| Op::SetRenderingIntent { .. }
| Op::SetHorizontalScaling { .. }
| Op::BeginInlineImage
| Op::BeginInlineImageData
| Op::EndInlineImage
| Op::BeginMarkedContent { .. }
| Op::BeginMarkedContentWithProperties { .. }
| Op::BeginOptionalContent { .. }
| Op::DefineMarkedContentPoint { .. }
| Op::EndMarkedContent
| Op::EndMarkedContentWithProperties
| Op::EndOptionalContent
| Op::BeginCompatibilitySection
| Op::EndCompatibilitySection
| Op::MoveToNextLineShowText { .. }
| Op::SetSpacingMoveAndShowText { .. }
| Op::Unknown { .. } => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
use azul_core::resources::{ImageRef, RawImageFormat};
use azul_layout::solver3::display_list::{
BorderRadius, DisplayList, DisplayListItem, WindowLogicalRect,
};
use azul_layout::text3::cache::FontManager;
fn empty_font_manager() -> FontManager<azul_css::props::basic::FontRef> {
FontManager::new(rust_fontconfig::FcFontCache::default())
.expect("build empty FontManager")
}
#[cfg(all(feature = "images", feature = "jpeg"))]
#[test]
fn image_item_emits_use_xobject() {
let cat_jpg: &[u8] = include_bytes!("../../examples/assets/img/cat.jpg");
let resolved = resolve_html_images(
&[("cat.jpg".to_string(), cat_jpg.to_vec())]
.into_iter()
.collect(),
);
let (_id, raw) = resolved.get("cat.jpg").expect("cat.jpg decoded");
assert!(raw.width > 0 && raw.height > 0, "decoded image has dimensions");
let image_ref = ImageRef::null_image(
raw.width,
raw.height,
RawImageFormat::RGBA8,
b"cat.jpg".to_vec(),
);
let bounds = WindowLogicalRect(LogicalRect {
origin: LogicalPosition { x: 10.0, y: 20.0 },
size: LogicalSize { width: 200.0, height: 100.0 },
});
let mut dl = DisplayList::default();
dl.items.push(DisplayListItem::Image {
bounds,
image: image_ref,
border_radius: BorderRadius::default(),
});
let fm = empty_font_manager();
let page = LogicalSize { width: 595.0, height: 842.0 }; let ops = display_list_to_printpdf_ops_with_margins(&dl, page, 0.0, 0.0, &fm, &resolved, &mut BridgeResources::default())
.expect("bridge conversion");
let use_xobject = ops.iter().find_map(|op| match op {
Op::UseXobject { id, transform } => Some((id.clone(), *transform)),
_ => None,
});
let (id, transform) = use_xobject.expect("an Op::UseXobject must be emitted for the image");
assert_eq!(id.0, "HtmlImg_cat_jpg", "deterministic xobject id");
assert_eq!(id, image_xobject_id("cat.jpg"), "id matches helper");
const IMAGE_DPI: f32 = 300.0;
let natural_w_pt = crate::Px(raw.width).into_pt(IMAGE_DPI).0;
let natural_h_pt = crate::Px(raw.height).into_pt(IMAGE_DPI).0;
let target_w_pt = 200.0 * (72.0 / 96.0);
let target_h_pt = 100.0 * (72.0 / 96.0);
let sx = transform.scale_x.expect("scale_x set");
let sy = transform.scale_y.expect("scale_y set");
assert!((natural_w_pt * sx - target_w_pt).abs() < 0.5, "image width fills bounds");
assert!((natural_h_pt * sy - target_h_pt).abs() < 0.5, "image height fills bounds");
let tx = transform.translate_x.expect("translate_x set").0;
let ty = transform.translate_y.expect("translate_y set").0;
assert!((tx - 10.0 * (72.0 / 96.0)).abs() < 0.5, "x placement");
let expect_ty = 842.0 - (20.0 + 100.0) * (72.0 / 96.0);
assert!((ty - expect_ty).abs() < 0.5, "y placement (bottom-left, flipped)");
}
#[test]
fn image_item_without_bytes_is_noop() {
let image_ref =
ImageRef::null_image(100, 50, RawImageFormat::RGBA8, b"missing.png".to_vec());
let bounds = WindowLogicalRect(LogicalRect {
origin: LogicalPosition { x: 0.0, y: 0.0 },
size: LogicalSize { width: 100.0, height: 50.0 },
});
let mut dl = DisplayList::default();
dl.items.push(DisplayListItem::Image {
bounds,
image: image_ref,
border_radius: BorderRadius::default(),
});
let fm = empty_font_manager();
let ops = display_list_to_printpdf_ops_with_margins(
&dl,
LogicalSize { width: 595.0, height: 842.0 },
0.0,
0.0,
&fm,
&ResolvedImages::new(),
&mut BridgeResources::default(),
)
.expect("bridge conversion");
assert!(
!ops.iter().any(|op| matches!(op, Op::UseXobject { .. })),
"no UseXobject when bytes are missing"
);
}
#[test]
fn translucent_rect_emits_extgstate_alpha() {
let bounds = WindowLogicalRect(LogicalRect {
origin: LogicalPosition { x: 0.0, y: 0.0 },
size: LogicalSize { width: 50.0, height: 50.0 },
});
let mut dl = DisplayList::default();
dl.items.push(DisplayListItem::Rect {
bounds,
color: ColorU { r: 255, g: 0, b: 0, a: 128 }, border_radius: BorderRadius::default(),
});
let fm = empty_font_manager();
let mut bridge_res = BridgeResources::default();
let ops = display_list_to_printpdf_ops_with_margins(
&dl,
LogicalSize { width: 595.0, height: 842.0 },
0.0,
0.0,
&fm,
&ResolvedImages::new(),
&mut bridge_res,
)
.expect("bridge conversion");
assert!(
ops.iter().any(|op| matches!(op, Op::LoadGraphicsState { .. })),
"translucent fill must load an ExtGState"
);
assert_eq!(bridge_res.extgstates.len(), 1, "one ExtGState recorded");
let (_, gs) = &bridge_res.extgstates[0];
assert!((gs.current_fill_alpha() - 128.0 / 255.0).abs() < 1e-3, "fill alpha set");
}
#[test]
fn opaque_rect_has_no_extgstate() {
let bounds = WindowLogicalRect(LogicalRect {
origin: LogicalPosition { x: 0.0, y: 0.0 },
size: LogicalSize { width: 50.0, height: 50.0 },
});
let mut dl = DisplayList::default();
dl.items.push(DisplayListItem::Rect {
bounds,
color: ColorU { r: 0, g: 0, b: 255, a: 255 },
border_radius: BorderRadius::default(),
});
let fm = empty_font_manager();
let mut bridge_res = BridgeResources::default();
let _ = display_list_to_printpdf_ops_with_margins(
&dl, LogicalSize { width: 595.0, height: 842.0 }, 0.0, 0.0, &fm,
&ResolvedImages::new(), &mut bridge_res,
).expect("bridge conversion");
assert!(bridge_res.extgstates.is_empty(), "opaque fill needs no ExtGState");
}
#[cfg(all(feature = "images", feature = "jpeg"))]
#[test]
fn registered_image_serializes_as_xobject() {
let cat_jpg: &[u8] = include_bytes!("../../examples/assets/img/cat.jpg");
let mut warnings = Vec::new();
let raw = RawImage::decode_from_bytes(cat_jpg, &mut warnings).expect("decode cat.jpg");
let mut doc = crate::PdfDocument::new("img test");
let id = image_xobject_id("cat.jpg");
doc.resources
.xobjects
.map
.insert(id.clone(), crate::XObject::Image(raw.clone()));
doc.pages.push(crate::PdfPage::new(
crate::Mm(210.0),
crate::Mm(297.0),
vec![Op::UseXobject {
id,
transform: XObjectTransform::default(),
}],
));
let bytes = doc.save(&crate::PdfSaveOptions::default(), &mut Vec::new());
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
let has_image = contains(b"/Subtype/Image") || contains(b"/Subtype /Image");
assert!(
has_image,
"serialized PDF must contain an Image XObject (len={})",
bytes.len()
);
let has_filter = contains(b"/Filter/DCTDecode") || contains(b"/Filter /DCTDecode");
assert!(has_filter, "image XObject should be a DCTDecode (JPEG) stream");
let has_width = contains(b"/Width") && contains(b"/Height");
assert!(has_width, "serialized PDF must declare image width/height");
}
}