use crate::model::{self, Paragraph};
use crate::render::dimension::Pt;
use crate::render::geometry::PtSize;
use crate::render::layout::section::{
FloatingImage, FloatingImageX, FloatingImageY, FloatingShape, PageParity,
};
use crate::render::resolve::shape_geometry::build_geometry;
use crate::render::resolve::shape_visuals::resolve_shape_visuals;
use super::convert::vml_style_length_to_pt;
use super::{BuildContext, BuildState};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AnchorFrame {
Page,
Stack,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ShapeAnchorClass {
All,
ParagraphAnchored,
PageAnchored,
}
use crate::render::layout::{live_mc_branch, McBranch};
fn find_anchor_images<'a>(
inlines: &'a [crate::model::Inline],
out: &mut Vec<&'a crate::model::Image>,
) {
use crate::model::{GraphicContent, ImagePlacement, Inline};
for inline in inlines {
match inline {
Inline::Image(img)
if matches!(img.placement, ImagePlacement::Anchor(_))
&& !matches!(img.graphic, Some(GraphicContent::WordProcessingShape(_))) =>
{
out.push(img);
}
Inline::Hyperlink(link) => find_anchor_images(&link.content, out),
Inline::Field(f) => find_anchor_images(&f.content, out),
Inline::AlternateContent(ac) => match live_mc_branch(ac) {
McBranch::Choices(choices) => {
for choice in choices {
find_anchor_images(&choice.content, out);
}
}
McBranch::Fallback(fallback) => find_anchor_images(fallback, out),
McBranch::Neither => {}
},
_ => {}
}
}
}
pub(super) fn extract_floating_images(
para: &Paragraph,
ctx: &BuildContext,
state: &BuildState,
frame: AnchorFrame,
) -> Vec<FloatingImage> {
use crate::model::ImagePlacement;
let mut anchor_imgs = Vec::new();
find_anchor_images(¶.content, &mut anchor_imgs);
let mut images = Vec::new();
for img in anchor_imgs {
let ImagePlacement::Anchor(ref anchor) = img.placement else {
continue;
};
let Some(rel_id) = crate::render::resolve::images::extract_image_rel_id(img) else {
continue;
};
let Some(image_data) = ctx.resolved.media.get(rel_id).cloned() else {
log::warn!(
"anchor image: rel_id={} missing from media table ({} entries)",
rel_id.as_str(),
ctx.resolved.media.len(),
);
continue;
};
let w = Pt::from(img.extent.width);
let h = Pt::from(img.extent.height);
let (x, y) = resolve_anchor_position(anchor, w, h, state, frame);
images.push(FloatingImage {
image_data,
size: PtSize::new(w, h),
src_rect: crate::render::resolve::images::extract_src_rect(img),
x,
y,
wrap_mode: crate::render::layout::section::WrapMode::from_model(&anchor.wrap),
dist_left: Pt::from(anchor.distance.left),
dist_right: Pt::from(anchor.distance.right),
behind_doc: anchor.behind_text,
});
}
extract_vml_floating_images(¶.content, state, frame, ctx, &mut images);
images
}
fn find_anchor_shapes<'a>(
inlines: &'a [crate::model::Inline],
out: &mut Vec<&'a crate::model::Image>,
) {
use crate::model::{GraphicContent, ImagePlacement, Inline};
for inline in inlines {
match inline {
Inline::Image(img)
if matches!(img.placement, ImagePlacement::Anchor(_))
&& matches!(img.graphic, Some(GraphicContent::WordProcessingShape(_))) =>
{
out.push(img);
}
Inline::Hyperlink(link) => find_anchor_shapes(&link.content, out),
Inline::Field(f) => find_anchor_shapes(&f.content, out),
Inline::AlternateContent(ac) => match live_mc_branch(ac) {
McBranch::Choices(choices) => {
for choice in choices {
find_anchor_shapes(&choice.content, out);
}
}
McBranch::Fallback(fallback) => find_anchor_shapes(fallback, out),
McBranch::Neither => {}
},
_ => {}
}
}
}
pub(super) fn extract_floating_shapes(
para: &Paragraph,
ctx: &BuildContext,
state: &mut BuildState,
frame: AnchorFrame,
restrict: ShapeAnchorClass,
) -> Vec<FloatingShape> {
use crate::model::{GraphicContent, ImagePlacement};
let mut shape_imgs = Vec::new();
find_anchor_shapes(¶.content, &mut shape_imgs);
let mut shapes = Vec::new();
for img in shape_imgs {
let ImagePlacement::Anchor(ref anchor) = img.placement else {
continue;
};
let class_match = match restrict {
ShapeAnchorClass::All => true,
ShapeAnchorClass::ParagraphAnchored => anchors_to_paragraph(anchor),
ShapeAnchorClass::PageAnchored => !anchors_to_paragraph(anchor),
};
if !class_match {
continue;
}
let wsp = match img.graphic.as_ref() {
Some(GraphicContent::WordProcessingShape(w)) => w,
_ => continue,
};
let shape_props = wsp.shape_properties.as_ref();
let geometry = match shape_props.and_then(|p| p.geometry.as_ref()) {
Some(g) => g,
None => continue, };
let w = Pt::from(img.extent.width);
let h = Pt::from(img.extent.height);
let extent = PtSize::new(w, h);
let shape_path = match build_geometry(geometry, extent) {
Some(p) => p,
None => continue, };
let visuals = resolve_shape_visuals(
shape_props,
wsp.style_line_ref.as_ref(),
wsp.style_effect_ref.as_ref(),
wsp.style_fill_ref.as_ref(),
ctx.resolved.theme.as_ref(),
);
let (rotation, flip_h, flip_v) = shape_props
.and_then(|p| p.transform.as_ref())
.map(|t| {
(
t.rotation
.unwrap_or_else(|| crate::model::dimension::Dimension::new(0)),
t.flip_h.unwrap_or(false),
t.flip_v.unwrap_or(false),
)
})
.unwrap_or((crate::model::dimension::Dimension::new(0), false, false));
let (x, y) = resolve_anchor_position(anchor, w, h, state, frame);
let text_commands = build_shape_text_commands(wsp, extent, ctx, state);
shapes.push(FloatingShape {
x,
y,
size: extent,
rotation,
flip_h,
flip_v,
wrap_mode: crate::render::layout::section::WrapMode::from_model(&anchor.wrap),
dist_left: Pt::from(anchor.distance.left),
dist_right: Pt::from(anchor.distance.right),
behind_doc: anchor.behind_text,
paths: shape_path.paths,
fill: visuals.fill,
stroke: visuals.stroke,
effects: visuals.effects,
text_commands,
});
}
extract_vml_primitive_shapes(¶.content, state, frame, &mut shapes);
shapes
}
fn extract_vml_floating_images(
inlines: &[crate::model::Inline],
state: &BuildState,
frame: AnchorFrame,
ctx: &BuildContext,
out: &mut Vec<FloatingImage>,
) {
use crate::model::Inline;
for inline in inlines {
match inline {
Inline::Pict(pict) => {
for primitive in &pict.primitives {
extract_vml_primitive_image(primitive, state, frame, ctx, out);
}
}
Inline::Hyperlink(link) => {
extract_vml_floating_images(&link.content, state, frame, ctx, out)
}
Inline::Field(f) => extract_vml_floating_images(&f.content, state, frame, ctx, out),
Inline::AlternateContent(ac) => match live_mc_branch(ac) {
McBranch::Choices(choices) => {
for choice in choices {
extract_vml_floating_images(&choice.content, state, frame, ctx, out);
}
}
McBranch::Fallback(fallback) => {
extract_vml_floating_images(fallback, state, frame, ctx, out)
}
McBranch::Neither => {}
},
_ => {}
}
}
}
fn extract_vml_primitive_image(
primitive: &model::VmlPrimitive,
state: &BuildState,
frame: AnchorFrame,
ctx: &BuildContext,
out: &mut Vec<FloatingImage>,
) {
use crate::model::VmlPrimitive;
match primitive {
VmlPrimitive::Image(img) => {
if let Some(fi) = build_vml_floating_image(&img.common, state, frame, ctx) {
out.push(fi);
}
}
VmlPrimitive::Shape(s) if s.common.image_data.is_some() && s.common.text_box.is_none() => {
if let Some(fi) = build_vml_floating_image(&s.common, state, frame, ctx) {
out.push(fi);
}
}
VmlPrimitive::Group(g) => {
for child in &g.children {
extract_vml_primitive_image(child, state, frame, ctx, out);
}
}
_ => {}
}
}
fn build_vml_floating_image(
common: &model::VmlCommonAttrs,
state: &BuildState,
frame: AnchorFrame,
ctx: &BuildContext,
) -> Option<FloatingImage> {
use crate::render::layout::section::WrapMode;
let rel_id = common.image_data.as_ref()?.rel_id.as_ref()?;
let image_data = ctx.resolved.media.get(rel_id).cloned()?;
let (page_x, y) = vml_absolute_position(&common.style)?;
let x = FloatingImageX::Absolute(match frame {
AnchorFrame::Page => page_x,
AnchorFrame::Stack => page_x - state.page_config.margins.left,
});
let width = common.style.width.and_then(vml_style_length_to_pt)?;
let height = common.style.height.and_then(vml_style_length_to_pt)?;
if width <= Pt::ZERO || height <= Pt::ZERO {
return None;
}
Some(FloatingImage {
image_data,
size: PtSize::new(width, height),
src_rect: None,
x,
y: FloatingImageY::RelativeToParagraph(y),
wrap_mode: WrapMode::None,
dist_left: Pt::ZERO,
dist_right: Pt::ZERO,
behind_doc: false,
})
}
fn extract_vml_primitive_shapes(
inlines: &[crate::model::Inline],
state: &BuildState,
frame: AnchorFrame,
out: &mut Vec<FloatingShape>,
) {
use crate::model::Inline;
for inline in inlines {
match inline {
Inline::Pict(pict) => {
for primitive in &pict.primitives {
extract_vml_primitive(primitive, state, frame, out);
}
}
Inline::Hyperlink(link) => {
extract_vml_primitive_shapes(&link.content, state, frame, out)
}
Inline::Field(f) => extract_vml_primitive_shapes(&f.content, state, frame, out),
Inline::AlternateContent(ac) => match live_mc_branch(ac) {
McBranch::Choices(choices) => {
for choice in choices {
extract_vml_primitive_shapes(&choice.content, state, frame, out);
}
}
McBranch::Fallback(fallback) => {
extract_vml_primitive_shapes(fallback, state, frame, out)
}
McBranch::Neither => {}
},
_ => {}
}
}
}
fn extract_vml_primitive(
primitive: &model::VmlPrimitive,
state: &BuildState,
frame: AnchorFrame,
out: &mut Vec<FloatingShape>,
) {
use crate::model::VmlPrimitive;
match primitive {
VmlPrimitive::Rect(r) => {
if let Some(shape) = build_vml_rect_shape(&r.common, state, frame) {
out.push(shape);
}
}
VmlPrimitive::RoundRect(r) => {
if let Some(shape) = build_vml_rect_shape(&r.common, state, frame) {
out.push(shape);
}
}
VmlPrimitive::Group(g) => {
for child in &g.children {
extract_vml_primitive(child, state, frame, out);
}
}
VmlPrimitive::Image(_) => {}
VmlPrimitive::Shape(_)
| VmlPrimitive::Oval(_)
| VmlPrimitive::Line(_)
| VmlPrimitive::PolyLine(_)
| VmlPrimitive::Arc(_)
| VmlPrimitive::Curve(_) => {}
}
}
fn build_vml_rect_shape(
common: &model::VmlCommonAttrs,
state: &BuildState,
frame: AnchorFrame,
) -> Option<FloatingShape> {
use crate::render::geometry::PtOffset;
use crate::render::resolve::shape_geometry::{PathVerb, SubPath};
let (page_x, y) = vml_absolute_position(&common.style)?;
let x = FloatingImageX::Absolute(match frame {
AnchorFrame::Page => page_x,
AnchorFrame::Stack => page_x - state.page_config.margins.left,
});
let width = common.style.width.and_then(vml_style_length_to_pt)?;
let height = common.style.height.and_then(vml_style_length_to_pt)?;
if width <= Pt::ZERO || height <= Pt::ZERO {
return None;
}
let extent = PtSize::new(width, height);
let fill = resolve_vml_solid_fill(common);
let paths = vec![SubPath {
verbs: vec![
PathVerb::MoveTo(PtOffset::new(Pt::ZERO, Pt::ZERO)),
PathVerb::LineTo(PtOffset::new(extent.width, Pt::ZERO)),
PathVerb::LineTo(PtOffset::new(extent.width, extent.height)),
PathVerb::LineTo(PtOffset::new(Pt::ZERO, extent.height)),
PathVerb::Close,
],
fill_mode: crate::model::PathFillMode::Norm,
stroked: matches!(common.stroked, Some(true)),
}];
let y_image = FloatingImageY::RelativeToParagraph(y);
Some(FloatingShape {
x,
y: y_image,
size: extent,
rotation: crate::model::dimension::Dimension::new(0),
flip_h: false,
flip_v: false,
wrap_mode: crate::render::layout::section::WrapMode::None,
dist_left: Pt::ZERO,
dist_right: Pt::ZERO,
behind_doc: false,
paths,
fill,
stroke: None,
effects: vec![],
text_commands: Vec::new(),
})
}
fn resolve_anchor_position(
anchor: &crate::model::AnchorProperties,
content_w: Pt,
content_h: Pt,
state: &BuildState,
frame: AnchorFrame,
) -> (FloatingImageX, FloatingImageY) {
let x = resolve_anchor_x(anchor, content_w, state, frame);
let y = resolve_anchor_y(anchor, content_h, state, frame);
(x, y)
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AnchorSpan {
start: Pt,
extent: Pt,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum HorizontalRegion {
Fixed(AnchorSpan),
Mirrored { odd: AnchorSpan, even: AnchorSpan },
}
impl HorizontalRegion {
fn on(self, parity: PageParity) -> AnchorSpan {
match self {
Self::Fixed(span) => span,
Self::Mirrored { odd, even } => match parity {
PageParity::Odd => odd,
PageParity::Even => even,
},
}
}
}
#[derive(Clone, Copy, Debug)]
struct FrameGeometry {
page_left: Pt,
page_width: Pt,
margin_left: Pt,
margin_right: Pt,
container: AnchorSpan,
}
impl FrameGeometry {
fn new(pc: &crate::render::layout::page::PageConfig, frame: AnchorFrame) -> Self {
let (margin_left, margin_right) = (pc.margins.left, pc.margins.right);
let page_width = pc.page_size.width;
let (page_left, container) = match frame {
AnchorFrame::Page => (
Pt::ZERO,
AnchorSpan {
start: margin_left,
extent: (page_width - margin_left - margin_right).max(Pt::ZERO),
},
),
AnchorFrame::Stack => (
-margin_left,
AnchorSpan {
start: Pt::ZERO,
extent: Pt::ZERO,
},
),
};
Self {
page_left,
page_width,
margin_left,
margin_right,
container,
}
}
fn page(&self) -> AnchorSpan {
AnchorSpan {
start: self.page_left,
extent: self.page_width,
}
}
fn left_margin(&self) -> AnchorSpan {
AnchorSpan {
start: self.page_left,
extent: self.margin_left,
}
}
fn right_margin(&self) -> AnchorSpan {
AnchorSpan {
start: self.page_left + self.page_width - self.margin_right,
extent: self.margin_right,
}
}
}
fn horizontal_region(
from: crate::model::AnchorRelativeFrom,
geom: &FrameGeometry,
) -> HorizontalRegion {
use crate::model::AnchorRelativeFrom as From;
use HorizontalRegion::{Fixed, Mirrored};
match from {
From::Page => Fixed(geom.page()),
From::Margin | From::Column => Fixed(geom.container),
From::LeftMargin => Fixed(geom.left_margin()),
From::RightMargin => Fixed(geom.right_margin()),
From::InsideMargin => Mirrored {
odd: geom.left_margin(),
even: geom.right_margin(),
},
From::OutsideMargin => Mirrored {
odd: geom.right_margin(),
even: geom.left_margin(),
},
From::Character => {
log::warn!(
"anchor: relativeFrom=\"character\" needs the anchor's position in \
the run, which float extraction runs before — positioning \
against the text area instead"
);
Fixed(geom.container)
}
From::Paragraph | From::Line | From::TopMargin | From::BottomMargin => {
log::warn!(
"anchor: relativeFrom={from:?} is not a horizontal reference \
(§20.4.3.4) — positioning against the text area instead"
);
Fixed(geom.container)
}
}
}
fn resolve_anchor_x(
anchor: &crate::model::AnchorProperties,
content_w: Pt,
state: &BuildState,
frame: AnchorFrame,
) -> FloatingImageX {
use crate::model::{AnchorAlignment, AnchorPosition};
let geom = FrameGeometry::new(&state.page_config, frame);
let at = |parity: PageParity| -> Pt {
match &anchor.horizontal_position {
AnchorPosition::Offset {
relative_from,
offset,
} => horizontal_region(*relative_from, &geom).on(parity).start + Pt::from(*offset),
AnchorPosition::Align {
relative_from,
alignment,
} => {
let span = horizontal_region(*relative_from, &geom).on(parity);
let near = span.start;
let far = span.start + span.extent - content_w;
match alignment {
AnchorAlignment::Left => near,
AnchorAlignment::Right => far,
AnchorAlignment::Center => span.start + (span.extent - content_w) * 0.5,
AnchorAlignment::Inside => match parity {
PageParity::Odd => near,
PageParity::Even => far,
},
AnchorAlignment::Outside => match parity {
PageParity::Odd => far,
PageParity::Even => near,
},
AnchorAlignment::Top | AnchorAlignment::Bottom => {
log::warn!(
"anchor: align={alignment:?} is not a horizontal alignment \
(§20.4.3.1) — placing at the region's left edge instead"
);
near
}
}
}
}
};
FloatingImageX::from_pages(at(PageParity::Odd), at(PageParity::Even))
}
fn resolve_anchor_y(
anchor: &crate::model::AnchorProperties,
content_h: Pt,
state: &BuildState,
frame: AnchorFrame,
) -> FloatingImageY {
use crate::model::{AnchorAlignment, AnchorPosition, AnchorRelativeFrom};
let pc = &state.page_config;
match &anchor.vertical_position {
AnchorPosition::Offset {
relative_from,
offset,
} => match frame {
AnchorFrame::Stack => FloatingImageY::RelativeToParagraph(Pt::from(*offset)),
AnchorFrame::Page => match relative_from {
AnchorRelativeFrom::Page => FloatingImageY::Absolute(Pt::from(*offset)),
AnchorRelativeFrom::Margin => {
FloatingImageY::Absolute(pc.margins.top + Pt::from(*offset))
}
AnchorRelativeFrom::TopMargin => FloatingImageY::Absolute(Pt::from(*offset)),
AnchorRelativeFrom::BottomMargin => FloatingImageY::Absolute(
pc.page_size.height - pc.margins.bottom + Pt::from(*offset),
),
AnchorRelativeFrom::Paragraph | AnchorRelativeFrom::Line => {
FloatingImageY::RelativeToParagraph(Pt::from(*offset))
}
AnchorRelativeFrom::InsideMargin | AnchorRelativeFrom::OutsideMargin => {
FloatingImageY::Absolute(pc.margins.top + Pt::from(*offset))
}
AnchorRelativeFrom::Column
| AnchorRelativeFrom::Character
| AnchorRelativeFrom::LeftMargin
| AnchorRelativeFrom::RightMargin => {
log::warn!(
"anchor: relativeFrom={relative_from:?} is not a vertical \
reference (§20.4.3.5) — positioning against the margin box"
);
FloatingImageY::Absolute(pc.margins.top + Pt::from(*offset))
}
},
},
AnchorPosition::Align {
relative_from,
alignment,
} => {
if frame == AnchorFrame::Stack {
return FloatingImageY::RelativeToParagraph(Pt::ZERO);
}
let (margin_top, page_height, margin_bottom) =
(pc.margins.top, pc.page_size.height, pc.margins.bottom);
let (area_top, area_height) = match relative_from {
AnchorRelativeFrom::Page => (Pt::ZERO, page_height),
AnchorRelativeFrom::Margin => (
margin_top,
(page_height - margin_top - margin_bottom).max(Pt::ZERO),
),
AnchorRelativeFrom::TopMargin => (Pt::ZERO, margin_top),
AnchorRelativeFrom::BottomMargin => (page_height - margin_bottom, margin_bottom),
AnchorRelativeFrom::InsideMargin
| AnchorRelativeFrom::OutsideMargin
| AnchorRelativeFrom::Paragraph
| AnchorRelativeFrom::Line => (
margin_top,
(page_height - margin_top - margin_bottom).max(Pt::ZERO),
),
AnchorRelativeFrom::Column
| AnchorRelativeFrom::Character
| AnchorRelativeFrom::LeftMargin
| AnchorRelativeFrom::RightMargin => {
log::warn!(
"anchor: relativeFrom={relative_from:?} is not a vertical \
reference (§20.4.3.5) — aligning within the margin box"
);
(
margin_top,
(page_height - margin_top - margin_bottom).max(Pt::ZERO),
)
}
};
let y_pos = match alignment {
AnchorAlignment::Top => area_top,
AnchorAlignment::Bottom => area_top + area_height - content_h,
AnchorAlignment::Center => area_top + (area_height - content_h) * 0.5,
AnchorAlignment::Inside | AnchorAlignment::Outside => area_top,
AnchorAlignment::Left | AnchorAlignment::Right => {
log::warn!(
"anchor: align={alignment:?} is not a vertical alignment \
(§20.4.3.2) — aligning to the region's top instead"
);
area_top
}
};
FloatingImageY::Absolute(y_pos)
}
}
}
pub(super) fn find_vml_absolute_position(inline: &model::Inline) -> Option<(Pt, Pt)> {
match inline {
model::Inline::Pict(pict) => find_vml_pos_in_pict(pict),
model::Inline::AlternateContent(ac) => match crate::render::layout::live_mc_branch(ac) {
crate::render::layout::McBranch::Fallback(fallback) => {
fallback.iter().find_map(find_vml_absolute_position)
}
crate::render::layout::McBranch::Choices(_)
| crate::render::layout::McBranch::Neither => None,
},
_ => None,
}
}
fn find_vml_pos_in_pict(pict: &model::Pict) -> Option<(Pt, Pt)> {
for shape in pict.shapes() {
if shape.common.text_box.is_some() {
if let Some(pos) = vml_absolute_position(&shape.common.style) {
return Some(pos);
}
}
}
None
}
fn vml_absolute_position(style: &model::VmlStyle) -> Option<(Pt, Pt)> {
use crate::model::CssPosition;
if style.position != Some(CssPosition::Absolute) {
return None;
}
let x = style.margin_left.and_then(vml_style_length_to_pt)?;
let y = style.margin_top.and_then(vml_style_length_to_pt)?;
Some((x, y))
}
fn resolve_vml_solid_fill(
common: &model::VmlCommonAttrs,
) -> crate::render::layout::draw_command::ResolvedFill {
use crate::model::{VmlColor, VmlFillType};
use crate::render::layout::draw_command::ResolvedFill;
use crate::render::resolve::drawing_color::Rgba;
let to_solid = |c: &VmlColor| -> Option<ResolvedFill> {
match c {
VmlColor::Rgb(r, g, b) => Some(ResolvedFill::Solid(Rgba {
r: *r as f32 / 255.0,
g: *g as f32 / 255.0,
b: *b as f32 / 255.0,
a: 1.0,
})),
VmlColor::Named(_) => None,
}
};
if let Some(ref fill) = common.fill {
match fill.fill_type {
VmlFillType::Solid => {
if let Some(c) = fill.color.as_ref().and_then(to_solid) {
return c;
}
}
VmlFillType::Gradient
| VmlFillType::GradientRadial
| VmlFillType::Tile
| VmlFillType::Frame
| VmlFillType::Pattern => {
log::warn!(
"vml: unsupported fill type {:?} — rendering as no-fill",
fill.fill_type
);
return ResolvedFill::None;
}
}
}
common
.fill_color
.as_ref()
.and_then(to_solid)
.unwrap_or(ResolvedFill::None)
}
fn anchors_to_paragraph(anchor: &crate::model::AnchorProperties) -> bool {
use crate::model::{AnchorPosition, AnchorRelativeFrom};
let relative_from = match &anchor.vertical_position {
AnchorPosition::Offset { relative_from, .. } => relative_from,
AnchorPosition::Align { relative_from, .. } => relative_from,
};
matches!(
relative_from,
AnchorRelativeFrom::Paragraph | AnchorRelativeFrom::Line
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BodyAnchor {
Top,
Center,
Bottom,
}
impl BodyAnchor {
fn resolve(anchor: Option<crate::model::TextAnchoringType>) -> Self {
use crate::model::TextAnchoringType as T;
match anchor {
None | Some(T::Top) => Self::Top,
Some(T::Center) => Self::Center,
Some(T::Bottom) => Self::Bottom,
Some(anchor @ (T::Justified | T::Distributed)) => {
log::warn!(
"shape text: anchor={anchor:?} distributes lines to fill the body \
(§20.1.10.60), which is not modelled — anchoring to the top instead"
);
Self::Top
}
}
}
fn offset(self, box_height: Pt, text_height: Pt) -> Pt {
let slack = (box_height - text_height).max(Pt::ZERO);
match self {
Self::Top => Pt::ZERO,
Self::Center => slack * 0.5,
Self::Bottom => slack,
}
}
}
pub(super) fn build_shape_text_commands(
wsp: &crate::model::WordProcessingShape,
extent: PtSize,
ctx: &BuildContext,
state: &BuildState,
) -> Vec<crate::render::layout::draw_command::DrawCommand> {
if wsp.txbx_content.is_empty() {
return Vec::new();
}
let default_lr = Pt::new(91440.0 / 12700.0); let default_tb = Pt::new(45720.0 / 12700.0); let (left_inset, top_inset, right_inset, bot_inset) =
wsp.body_pr
.as_ref()
.map_or((default_lr, default_tb, default_lr, default_tb), |bp| {
(
bp.left_inset.map_or(default_lr, Pt::from),
bp.top_inset.map_or(default_tb, Pt::from),
bp.right_inset.map_or(default_lr, Pt::from),
bp.bottom_inset.map_or(default_tb, Pt::from),
)
});
let content_width = (extent.width - left_inset - right_inset).max(Pt::ZERO);
if content_width <= Pt::ZERO {
return Vec::new();
}
let theme = ctx.resolved.theme.as_ref();
let (shape_default_text_color, shape_default_font_family) = match &wsp.style_font_ref {
Some(fr) => {
let color = fr.color.as_ref().map(|c| {
let dc = crate::render::resolve::drawing_color::DrawingColorContext::new(theme);
let rgba = crate::render::resolve::drawing_color::resolve_drawing_color(c, &dc);
crate::render::resolve::color::rgb_from_u32(rgba.to_rgb24())
});
let family = theme.and_then(|t| {
let fam = match fr.collection {
crate::model::FontCollectionIndex::Major => t.major_font.latin.clone(),
crate::model::FontCollectionIndex::Minor => t.minor_font.latin.clone(),
crate::model::FontCollectionIndex::None => String::new(),
};
(!fam.is_empty()).then_some(fam)
});
(color, family)
}
None => (None, None),
};
let auto_fit = crate::render::layout::ShapeAutoFit::from_body(
wsp.body_pr.as_ref().and_then(|bp| bp.auto_fit),
);
let mut sub_state = BuildState {
outline: crate::render::layout::build::OutlineCollector::Excluded,
shape_auto_fit: auto_fit,
page_config: state.page_config.clone(),
footnotes: Default::default(),
endnote_counter: 0,
list_counters: std::collections::HashMap::new(),
field_ctx: state.field_ctx,
shape_default_text_color,
shape_default_font_family,
warned_border_styles: std::collections::HashSet::new(),
warned_row_cell_spacing: false,
};
let hf = super::build_header_footer_content(&wsp.txbx_content, ctx, &mut sub_state);
let line_height = auto_fit.scale_font(super::default_line_height(ctx));
let result = crate::render::layout::section::stack_blocks(
&hf.blocks,
content_width,
line_height,
None,
PageParity::Odd,
);
let content_height = (extent.height - top_inset - bot_inset).max(Pt::ZERO);
let anchor = BodyAnchor::resolve(wsp.body_pr.as_ref().and_then(|bp| bp.anchor));
let body_top = top_inset + anchor.offset(content_height, result.height);
let overflow = wsp
.body_pr
.as_ref()
.and_then(|bp| bp.vert_overflow)
.unwrap_or_default();
let box_bottom = top_inset + content_height;
let mut commands = Vec::with_capacity(result.commands.len());
for mut cmd in result.commands {
cmd.shift(left_inset, body_top);
if !overflow_keeps(overflow, &cmd, box_bottom) {
continue;
}
commands.push(cmd);
}
commands
}
fn overflow_keeps(
overflow: crate::model::TextVertOverflow,
cmd: &crate::render::layout::draw_command::DrawCommand,
box_bottom: Pt,
) -> bool {
use crate::model::TextVertOverflow;
match overflow {
TextVertOverflow::Overflow => true,
TextVertOverflow::Clip | TextVertOverflow::Ellipsis => cmd
.vertical_span()
.is_none_or(|(_, bottom)| bottom <= box_bottom),
}
}
#[cfg(test)]
mod tests {
use super::find_vml_absolute_position;
use crate::model::dimension::Dimension;
use crate::model::geometry::{EdgeInsets, Size};
use crate::model::{
AlternateContent, AnchorPosition, AnchorProperties, AnchorRelativeFrom, DocProperties,
GraphicContent, Image, ImagePlacement, Inline, McChoice, McRequires, TextWrap,
WordProcessingShape,
};
use crate::render::layout::{live_mc_branch, McBranch};
fn anchored_wps_image() -> Image {
Image {
extent: Size::new(Dimension::new(0), Dimension::new(0)),
effect_extent: None,
doc_properties: DocProperties {
id: 1,
name: "shape".into(),
description: None,
hidden: None,
title: None,
},
graphic_frame_locks: None,
graphic: Some(GraphicContent::WordProcessingShape(WordProcessingShape {
cnv_pr: None,
shape_properties: None,
style_line_ref: None,
style_effect_ref: None,
style_fill_ref: None,
style_font_ref: None,
body_pr: None,
txbx_content: vec![],
})),
placement: ImagePlacement::Anchor(AnchorProperties {
distance: EdgeInsets::new(
Dimension::new(0),
Dimension::new(0),
Dimension::new(0),
Dimension::new(0),
),
simple_pos: None,
use_simple_pos: None,
horizontal_position: AnchorPosition::Offset {
relative_from: AnchorRelativeFrom::Margin,
offset: Dimension::new(0),
},
vertical_position: AnchorPosition::Offset {
relative_from: AnchorRelativeFrom::Paragraph,
offset: Dimension::new(0),
},
wrap: TextWrap::None,
behind_text: false,
lock_anchor: false,
allow_overlap: true,
relative_height: 0,
layout_in_cell: None,
hidden: None,
}),
}
}
fn ac_with_wps_choice() -> AlternateContent {
AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wps],
content: vec![Inline::Image(Box::new(anchored_wps_image()))],
}],
fallback: Some(vec![Inline::InstrText(String::new())]),
}
}
#[test]
fn an_anchored_shape_in_a_choice_makes_that_choice_live() {
assert!(matches!(
live_mc_branch(&ac_with_wps_choice()),
McBranch::Choices(_)
));
}
#[test]
fn a_choice_with_nothing_anchored_yields_to_the_fallback() {
let ac = AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wps],
content: vec![Inline::InstrText(String::new())],
}],
fallback: Some(vec![Inline::InstrText(String::new())]),
};
assert!(matches!(live_mc_branch(&ac), McBranch::Fallback(_)));
}
#[test]
fn no_drawable_choice_and_no_fallback_is_neither() {
let ac = AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wps],
content: vec![Inline::InstrText(String::new())],
}],
fallback: None,
};
assert!(matches!(live_mc_branch(&ac), McBranch::Neither));
}
#[test]
fn an_anchored_picture_in_a_choice_is_just_as_live_as_a_shape() {
let ac = AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wpg],
content: vec![Inline::Image(Box::new(anchored_picture()))],
}],
fallback: Some(vec![Inline::InstrText(String::new())]),
};
assert!(matches!(live_mc_branch(&ac), McBranch::Choices(_)));
}
#[test]
fn a_nested_alternate_content_resolves_innermost_first() {
let nested = AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wps],
content: vec![Inline::Image(Box::new(anchored_wps_image()))],
}],
fallback: None,
};
let outer = AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wps],
content: vec![Inline::AlternateContent(nested)],
}],
fallback: Some(vec![Inline::InstrText(String::new())]),
};
assert!(matches!(live_mc_branch(&outer), McBranch::Choices(_)));
}
#[test]
fn a_nested_alternate_content_that_draws_nothing_frees_the_outer_fallback() {
let nested = AlternateContent {
choices: vec![],
fallback: Some(vec![Inline::InstrText(String::new())]),
};
let outer = AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wps],
content: vec![Inline::AlternateContent(nested)],
}],
fallback: Some(vec![Inline::InstrText(String::new())]),
};
assert!(matches!(live_mc_branch(&outer), McBranch::Fallback(_)));
}
fn positioned_vml_text_box() -> Inline {
use crate::model::{
CssPosition, Pict, VmlCommonAttrs, VmlLength, VmlLengthUnit, VmlPrimitive, VmlShape,
VmlStyle, VmlTextBox,
};
let pt = |value| {
Some(VmlLength {
value,
unit: VmlLengthUnit::Pt,
})
};
Inline::Pict(Pict {
shape_type: None,
primitives: vec![VmlPrimitive::Shape(VmlShape {
common: VmlCommonAttrs {
style: VmlStyle {
position: Some(CssPosition::Absolute),
margin_left: pt(100.0),
margin_top: pt(40.0),
..VmlStyle::default()
},
text_box: Some(VmlTextBox {
style: VmlStyle::default(),
inset: None,
content: vec![],
}),
..VmlCommonAttrs::default()
},
shape_type_ref: None,
vml_path: None,
})],
})
}
#[test]
fn a_positioned_vml_text_box_has_an_absolute_position() {
assert!(find_vml_absolute_position(&positioned_vml_text_box()).is_some());
}
#[test]
fn a_drawable_choice_suppresses_its_fallbacks_absolute_position() {
let mut ac = ac_with_wps_choice();
ac.fallback = Some(vec![positioned_vml_text_box()]);
assert!(find_vml_absolute_position(&Inline::AlternateContent(ac)).is_none());
}
#[test]
fn a_live_fallbacks_absolute_position_is_the_one_that_counts() {
let ac = AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wps],
content: vec![Inline::InstrText(String::new())],
}],
fallback: Some(vec![positioned_vml_text_box()]),
};
assert!(find_vml_absolute_position(&Inline::AlternateContent(ac)).is_some());
}
use super::{resolve_anchor_y, AnchorFrame};
use crate::model::AnchorAlignment;
use crate::render::dimension::Pt;
use crate::render::layout::build::BuildState;
use crate::render::layout::section::FloatingImageY;
use crate::render::layout::section::{FloatingImageX, PageParity};
fn default_state() -> BuildState {
BuildState {
page_config: Default::default(),
outline: Default::default(),
shape_auto_fit: crate::render::layout::ShapeAutoFit::NONE,
footnotes: Default::default(),
endnote_counter: 0,
list_counters: Default::default(),
field_ctx: Default::default(),
warned_border_styles: Default::default(),
warned_row_cell_spacing: false,
shape_default_text_color: None,
shape_default_font_family: None,
}
}
fn anchor_with_v(vertical_position: AnchorPosition) -> AnchorProperties {
let ImagePlacement::Anchor(mut a) = anchored_wps_image().placement else {
unreachable!("fixture is anchored")
};
a.vertical_position = vertical_position;
a
}
fn v_align(alignment: AnchorAlignment) -> AnchorProperties {
anchor_with_v(AnchorPosition::Align {
relative_from: AnchorRelativeFrom::Margin,
alignment,
})
}
#[test]
fn stack_frame_align_is_paragraph_relative() {
let state = default_state();
for alignment in [
AnchorAlignment::Top,
AnchorAlignment::Center,
AnchorAlignment::Bottom,
] {
let y = resolve_anchor_y(
&v_align(alignment),
Pt::new(50.0),
&state,
AnchorFrame::Stack,
);
let FloatingImageY::RelativeToParagraph(offset) = y else {
panic!("{alignment:?} in Stack frame must be paragraph-relative");
};
assert_eq!(offset, Pt::ZERO, "{alignment:?} collapses to the paragraph");
}
}
#[test]
fn stack_frame_offset_is_paragraph_relative() {
let anchor = anchor_with_v(AnchorPosition::Offset {
relative_from: AnchorRelativeFrom::Margin,
offset: Dimension::new(914400), });
let y = resolve_anchor_y(&anchor, Pt::new(50.0), &default_state(), AnchorFrame::Stack);
let FloatingImageY::RelativeToParagraph(offset) = y else {
panic!("Offset in Stack frame must be paragraph-relative");
};
assert!((offset.raw() - 72.0).abs() < 1e-3, "1in = 72pt");
}
#[test]
fn page_frame_align_resolves_against_margin_box() {
let state = default_state();
let content_h = Pt::new(50.0);
let cases = [
(AnchorAlignment::Top, 72.0),
(AnchorAlignment::Center, 72.0 + (648.0 - 50.0) * 0.5),
(AnchorAlignment::Bottom, 72.0 + 648.0 - 50.0),
];
for (alignment, expected) in cases {
let y = resolve_anchor_y(&v_align(alignment), content_h, &state, AnchorFrame::Page);
let FloatingImageY::Absolute(got) = y else {
panic!("{alignment:?} in Page frame must be absolute");
};
assert!(
(got.raw() - expected).abs() < 1e-3,
"{alignment:?}: expected {expected}, got {}",
got.raw()
);
}
}
use super::build_shape_text_commands;
use crate::model::{
BodyProperties, Paragraph as ModelParagraph, ParagraphProperties, RunElement,
RunProperties, TextAnchoringType, TextRun,
};
use crate::render::fonts::FontRegistry;
use crate::render::geometry::PtSize;
use crate::render::layout::build::BuildContext;
use crate::render::layout::measurer::TextMeasurer;
use crate::render::resolve::ResolvedDocument;
fn empty_resolved() -> ResolvedDocument {
use std::collections::HashMap;
ResolvedDocument {
sections: Vec::new(),
styles: HashMap::new(),
numbering: HashMap::new(),
font_families: Vec::new(),
media: HashMap::new(),
embedded_fonts: Vec::new(),
pic_bullets: HashMap::new(),
theme: None,
doc_defaults_paragraph: ParagraphProperties::default(),
doc_defaults_run: RunProperties::default(),
default_paragraph_style_id: None,
footnotes: HashMap::new(),
endnotes: HashMap::new(),
even_and_odd_headers: false,
default_tab_stop: Dimension::new(720),
}
}
fn wsp_with_text(body_pr: Option<BodyProperties>) -> WordProcessingShape {
WordProcessingShape {
cnv_pr: None,
shape_properties: None,
style_line_ref: None,
style_effect_ref: None,
style_fill_ref: None,
style_font_ref: None,
body_pr,
txbx_content: vec![crate::model::Block::Paragraph(Box::new(ModelParagraph {
style_id: None,
properties: ParagraphProperties::default(),
mark_run_properties: None,
content: vec![Inline::TextRun(Box::new(TextRun {
style_id: None,
properties: RunProperties::default(),
content: vec![RunElement::Text("hi".into())],
rsids: crate::model::RevisionIds::default(),
}))],
rsids: crate::model::ParagraphRevisionIds::default(),
}))],
}
}
fn body_pr(anchor: Option<TextAnchoringType>, inset_emu: i64) -> BodyProperties {
BodyProperties {
rotation: None,
vert_overflow: None,
vert: None,
wrap: None,
left_inset: Some(Dimension::new(inset_emu)),
top_inset: Some(Dimension::new(inset_emu)),
right_inset: Some(Dimension::new(inset_emu)),
bottom_inset: Some(Dimension::new(inset_emu)),
anchor,
auto_fit: None,
}
}
fn shape_text_y(wsp: &WordProcessingShape, extent: PtSize) -> f32 {
let resolved = empty_resolved();
let registry = FontRegistry::new(skia_safe::FontMgr::new());
let measurer = TextMeasurer::new(®istry);
let ctx = BuildContext {
measurer: &measurer,
resolved: &resolved,
};
let state = BuildState::default();
let commands = build_shape_text_commands(wsp, extent, &ctx, &state);
commands
.iter()
.find_map(|c| match c {
crate::render::layout::draw_command::DrawCommand::Text { position, .. } => {
Some(position.y.raw())
}
_ => None,
})
.expect("the shape body emits text")
}
#[test]
fn body_anchor_places_text_within_the_inset_box() {
const INSET: f32 = 4.0;
const BOX_HEIGHT: f32 = 120.0 - 2.0 * INSET;
let extent = PtSize::new(Pt::new(200.0), Pt::new(120.0));
let y = |anchor| shape_text_y(&wsp_with_text(Some(body_pr(Some(anchor), 50800))), extent);
let (top, centre, bottom) = (
y(TextAnchoringType::Top),
y(TextAnchoringType::Center),
y(TextAnchoringType::Bottom),
);
assert!(
top < centre && centre < bottom,
"t < ctr < b, got {top} / {centre} / {bottom}"
);
let (half, full) = (centre - top, bottom - top);
assert!(
(full - 2.0 * half).abs() < 1e-3,
"the centre offset is half the bottom offset, got {half} / {full}"
);
let line_height = BOX_HEIGHT - full;
assert!(
line_height > 0.0 && line_height < BOX_HEIGHT,
"one line fits inside the 112pt box, implied height {line_height}"
);
}
#[test]
fn the_top_inset_shifts_a_top_anchored_body_one_for_one() {
let extent = PtSize::new(Pt::new(200.0), Pt::new(120.0));
let anchor = Some(TextAnchoringType::Top);
let flush = shape_text_y(&wsp_with_text(Some(body_pr(anchor, 0))), extent);
let inset = shape_text_y(&wsp_with_text(Some(body_pr(anchor, 50800))), extent);
assert!(
(inset - flush - 4.0).abs() < 1e-3,
"a 4pt top inset moves the body 4pt down, got {flush} → {inset}"
);
}
#[test]
fn the_bottom_inset_shifts_a_bottom_anchored_body_one_for_one() {
let extent = PtSize::new(Pt::new(200.0), Pt::new(120.0));
let anchor = Some(TextAnchoringType::Bottom);
let flush = shape_text_y(&wsp_with_text(Some(body_pr(anchor, 0))), extent);
let inset = shape_text_y(&wsp_with_text(Some(body_pr(anchor, 50800))), extent);
assert!(
(flush - inset - 4.0).abs() < 1e-3,
"a 4pt bottom inset lifts the body 4pt, got {flush} → {inset}"
);
}
#[test]
fn a_shape_without_body_properties_keeps_the_spec_defaults() {
let extent = PtSize::new(Pt::new(200.0), Pt::new(120.0));
let bare = shape_text_y(&wsp_with_text(None), extent);
let explicit = shape_text_y(
&wsp_with_text(Some(body_pr(Some(TextAnchoringType::Top), 45720))),
extent,
);
assert!(
(bare - explicit).abs() < 1e-3,
"an absent bodyPr matches the spec defaults spelled out, \
got {bare} vs {explicit}"
);
}
#[test]
fn an_overflowing_body_is_not_pushed_above_the_shape() {
let extent = PtSize::new(Pt::new(200.0), Pt::new(6.0));
let y = |anchor| shape_text_y(&wsp_with_text(Some(body_pr(Some(anchor), 50800))), extent);
let top = y(TextAnchoringType::Top);
for anchor in [TextAnchoringType::Center, TextAnchoringType::Bottom] {
assert!(
(y(anchor) - top).abs() < 1e-3,
"{anchor:?} on an overflowing body places as `t` does, got {} vs {top}",
y(anchor)
);
}
}
use super::{find_anchor_images, find_anchor_shapes};
use crate::model::{Blip, BlipFill, BlipFillKind, NvPicProperties, Picture, RelId};
fn anchored_picture() -> Image {
let mut img = anchored_wps_image();
img.graphic = Some(GraphicContent::Picture(Picture {
nv_pic_pr: NvPicProperties {
cnv_pr: DocProperties {
id: 2,
name: "picture".into(),
description: None,
hidden: None,
title: None,
},
cnv_pic_pr: None,
},
blip_fill: BlipFill {
rotate_with_shape: None,
dpi: None,
blip: Some(Blip {
embed: Some(RelId::new("rId7")),
link: None,
compression: None,
}),
src_rect: None,
fill_kind: BlipFillKind::Unspecified,
},
shape_properties: None,
}));
img
}
fn ac_of(choice: Vec<Inline>, fallback: Vec<Inline>) -> Inline {
Inline::AlternateContent(AlternateContent {
choices: vec![McChoice {
requires: vec![McRequires::Wpg],
content: choice,
}],
fallback: Some(fallback),
})
}
#[test]
fn both_anchor_walkers_read_the_same_alternate_content_branch() {
let content = vec![ac_of(
vec![Inline::Image(Box::new(anchored_picture()))],
vec![Inline::Image(Box::new(anchored_wps_image()))],
)];
let mut images = Vec::new();
find_anchor_images(&content, &mut images);
let mut shapes = Vec::new();
find_anchor_shapes(&content, &mut shapes);
assert_eq!(images.len(), 1, "the Choice's picture is live");
assert_eq!(
shapes.len(),
0,
"the Fallback's shape is not — the Choice was selected"
);
}
#[test]
fn an_anchored_picture_in_a_choice_is_found() {
let content = vec![ac_of(
vec![Inline::Image(Box::new(anchored_picture()))],
vec![Inline::InstrText(String::new())],
)];
let mut images = Vec::new();
find_anchor_images(&content, &mut images);
assert_eq!(images.len(), 1, "the Choice's anchored picture is rendered");
}
#[test]
fn an_unrenderable_choice_hands_the_document_to_the_fallback() {
let content = vec![ac_of(
vec![Inline::InstrText(String::new())],
vec![Inline::Image(Box::new(anchored_picture()))],
)];
let mut images = Vec::new();
find_anchor_images(&content, &mut images);
assert_eq!(images.len(), 1, "the Fallback's picture is live");
}
#[test]
fn a_wps_choice_still_suppresses_its_vml_fallback() {
let content = vec![ac_of(
vec![Inline::Image(Box::new(anchored_wps_image()))],
vec![Inline::Image(Box::new(anchored_picture()))],
)];
let mut images = Vec::new();
find_anchor_images(&content, &mut images);
let mut shapes = Vec::new();
find_anchor_shapes(&content, &mut shapes);
assert_eq!(shapes.len(), 1, "the Choice's shape renders");
assert_eq!(images.len(), 0, "the Fallback's picture does not");
}
use super::resolve_anchor_x;
const INCH: i64 = 914400;
fn anchor_with_h(horizontal_position: AnchorPosition) -> AnchorProperties {
let ImagePlacement::Anchor(mut a) = anchored_wps_image().placement else {
unreachable!("fixture is anchored")
};
a.horizontal_position = horizontal_position;
a
}
fn h_offset(relative_from: AnchorRelativeFrom, offset: i64) -> AnchorProperties {
anchor_with_h(AnchorPosition::Offset {
relative_from,
offset: Dimension::new(offset),
})
}
fn h_align(relative_from: AnchorRelativeFrom, alignment: AnchorAlignment) -> AnchorProperties {
anchor_with_h(AnchorPosition::Align {
relative_from,
alignment,
})
}
fn x_on(anchor: &AnchorProperties, frame: AnchorFrame, parity: PageParity) -> f32 {
resolve_anchor_x(anchor, Pt::new(100.0), &default_state(), frame)
.resolve(parity)
.raw()
}
fn x_of(anchor: &AnchorProperties, frame: AnchorFrame) -> f32 {
x_on(anchor, frame, PageParity::Odd)
}
fn assert_x(got: f32, expected: f32, what: &str) {
assert!(
(got - expected).abs() < 1e-3,
"{what}: expected {expected}, got {got}"
);
}
#[test]
fn page_frame_page_relative_offset_is_a_page_coordinate() {
assert_x(
x_of(&h_offset(AnchorRelativeFrom::Page, INCH), AnchorFrame::Page),
72.0,
"1in from the page's left edge",
);
}
#[test]
fn page_frame_margin_relative_offset_starts_at_the_margin() {
assert_x(
x_of(
&h_offset(AnchorRelativeFrom::Margin, INCH),
AnchorFrame::Page,
),
144.0,
"1in past the 1in left margin",
);
}
#[test]
fn stack_frame_page_relative_offset_backs_out_the_left_margin() {
assert_x(
x_of(
&h_offset(AnchorRelativeFrom::Page, INCH),
AnchorFrame::Stack,
),
0.0,
"72pt page coordinate minus the 72pt margin the caller re-adds",
);
}
#[test]
fn stack_frame_margin_relative_offset_is_frame_relative() {
assert_x(
x_of(
&h_offset(AnchorRelativeFrom::Margin, INCH),
AnchorFrame::Stack,
),
72.0,
"1in from the frame origin",
);
}
#[test]
fn page_frame_align_resolves_against_the_text_area() {
for (alignment, expected) in [
(AnchorAlignment::Left, 72.0),
(AnchorAlignment::Center, 72.0 + (468.0 - 100.0) * 0.5),
(AnchorAlignment::Right, 72.0 + 468.0 - 100.0),
] {
let got = x_of(
&h_align(AnchorRelativeFrom::Margin, alignment),
AnchorFrame::Page,
);
assert_x(
got,
expected,
&format!("{alignment:?} within the text area"),
);
}
}
#[test]
fn page_frame_page_align_resolves_against_the_whole_page() {
for (alignment, expected) in [
(AnchorAlignment::Left, 0.0),
(AnchorAlignment::Center, (612.0 - 100.0) * 0.5),
(AnchorAlignment::Right, 612.0 - 100.0),
] {
let got = x_of(
&h_align(AnchorRelativeFrom::Page, alignment),
AnchorFrame::Page,
);
assert_x(got, expected, &format!("{alignment:?} within the page"));
}
}
#[test]
fn stack_frame_align_collapses_to_the_frame_origin() {
for (alignment, expected) in [
(AnchorAlignment::Left, 0.0),
(AnchorAlignment::Center, -50.0),
(AnchorAlignment::Right, -100.0),
] {
let got = x_of(
&h_align(AnchorRelativeFrom::Margin, alignment),
AnchorFrame::Stack,
);
assert_x(got, expected, &format!("{alignment:?} in a stack frame"));
}
}
#[test]
fn left_margin_is_the_strip_from_the_page_edge_to_the_margin() {
let from = AnchorRelativeFrom::LeftMargin;
assert_x(
x_of(&h_offset(from, INCH), AnchorFrame::Page),
72.0,
"1in from the sheet's left edge",
);
assert_x(
x_of(&h_align(from, AnchorAlignment::Left), AnchorFrame::Page),
0.0,
"flush with the sheet's left edge",
);
assert_x(
x_of(&h_align(from, AnchorAlignment::Right), AnchorFrame::Page),
-28.0,
"right edge on the margin edge",
);
}
#[test]
fn right_margin_is_the_strip_from_the_margin_to_the_page_edge() {
let from = AnchorRelativeFrom::RightMargin;
assert_x(
x_of(&h_offset(from, INCH), AnchorFrame::Page),
612.0,
"1in past the right margin edge, i.e. the sheet's right edge",
);
assert_x(
x_of(&h_align(from, AnchorAlignment::Left), AnchorFrame::Page),
540.0,
"flush with the right margin edge",
);
}
#[test]
fn parity_margins_take_their_odd_page_reading() {
let inside = x_of(
&h_align(AnchorRelativeFrom::InsideMargin, AnchorAlignment::Left),
AnchorFrame::Page,
);
let outside = x_of(
&h_align(AnchorRelativeFrom::OutsideMargin, AnchorAlignment::Left),
AnchorFrame::Page,
);
assert_x(inside, 0.0, "inside = the left margin on an odd page");
assert_x(outside, 540.0, "outside = the right margin on an odd page");
}
#[test]
fn inside_and_outside_margins_mirror_each_other() {
use super::{horizontal_region, FrameGeometry, HorizontalRegion::Mirrored};
let geom = FrameGeometry::new(&default_state().page_config, AnchorFrame::Page);
let (
Mirrored {
odd: inside_odd,
even: inside_even,
},
Mirrored {
odd: outside_odd,
even: outside_even,
},
) = (
horizontal_region(AnchorRelativeFrom::InsideMargin, &geom),
horizontal_region(AnchorRelativeFrom::OutsideMargin, &geom),
)
else {
panic!("both references are parity-dependent");
};
assert_eq!(inside_odd, outside_even, "inside on odd = outside on even");
assert_eq!(inside_even, outside_odd, "inside on even = outside on odd");
assert_ne!(inside_odd, inside_even, "the two pages differ");
}
#[test]
fn inside_and_outside_alignments_mirror_on_even_pages() {
let margin = AnchorRelativeFrom::Margin;
for (alignment, odd, even) in [
(AnchorAlignment::Inside, 72.0, 440.0),
(AnchorAlignment::Outside, 440.0, 72.0),
] {
let anchor = h_align(margin, alignment);
assert_x(
x_on(&anchor, AnchorFrame::Page, PageParity::Odd),
odd,
&format!("{alignment:?} on an odd page"),
);
assert_x(
x_on(&anchor, AnchorFrame::Page, PageParity::Even),
even,
&format!("{alignment:?} on an even page"),
);
}
}
#[test]
fn a_mirrored_alignment_inside_a_mirrored_region_mirrors_once() {
let anchor = h_align(AnchorRelativeFrom::InsideMargin, AnchorAlignment::Inside);
assert_x(
x_on(&anchor, AnchorFrame::Page, PageParity::Odd),
0.0,
"odd",
);
assert_x(
x_on(&anchor, AnchorFrame::Page, PageParity::Even),
540.0 + 72.0 - 100.0,
"even",
);
}
#[test]
fn an_unmirrored_anchor_carries_no_deferral() {
for alignment in [
AnchorAlignment::Left,
AnchorAlignment::Center,
AnchorAlignment::Right,
] {
let x = resolve_anchor_x(
&h_align(AnchorRelativeFrom::Margin, alignment),
Pt::new(100.0),
&default_state(),
AnchorFrame::Page,
);
assert!(
matches!(x, FloatingImageX::Absolute(_)),
"{alignment:?} is parity-independent, got {x:?}"
);
}
let mirrored = resolve_anchor_x(
&h_align(AnchorRelativeFrom::Margin, AnchorAlignment::Inside),
Pt::new(100.0),
&default_state(),
AnchorFrame::Page,
);
assert!(
matches!(mirrored, FloatingImageX::PageParity { .. }),
"inside is parity-dependent, got {mirrored:?}"
);
}
#[test]
fn character_relative_falls_back_to_the_text_area() {
let from = AnchorRelativeFrom::Character;
assert_x(
x_of(&h_offset(from, INCH), AnchorFrame::Page),
144.0,
"offset",
);
assert_x(
x_of(&h_align(from, AnchorAlignment::Left), AnchorFrame::Page),
72.0,
"align",
);
}
#[test]
fn stack_frame_margin_strips_survive_the_round_trip_into_page_space() {
const BODY_MARGIN: f32 = 72.0;
for (from, page_x) in [
(AnchorRelativeFrom::LeftMargin, 0.0),
(AnchorRelativeFrom::RightMargin, 540.0),
] {
let got = x_of(&h_align(from, AnchorAlignment::Left), AnchorFrame::Stack);
assert_x(
got + BODY_MARGIN,
page_x,
&format!("{from:?} after the caller's shift"),
);
}
}
use super::{build_vml_rect_shape, model, resolve_vml_solid_fill};
use crate::model::{VmlColor, VmlFill, VmlFillType, VmlLength, VmlLengthUnit, VmlNamedColor};
use crate::render::layout::draw_command::ResolvedFill;
fn solid_rgb(fill: &ResolvedFill) -> (f32, f32, f32) {
let ResolvedFill::Solid(c) = fill else {
panic!("expected a solid fill, got {fill:?}");
};
(c.r, c.g, c.b)
}
#[test]
fn vml_fill_child_overrides_the_fillcolor_attribute() {
let common = model::VmlCommonAttrs {
fill_color: Some(VmlColor::Rgb(255, 0, 0)),
fill: Some(VmlFill {
fill_type: VmlFillType::Solid,
color: Some(VmlColor::Rgb(0, 255, 0)),
..Default::default()
}),
..Default::default()
};
assert_eq!(solid_rgb(&resolve_vml_solid_fill(&common)), (0.0, 1.0, 0.0));
}
#[test]
fn vml_fillcolor_attribute_applies_without_a_fill_child() {
let common = model::VmlCommonAttrs {
fill_color: Some(VmlColor::Rgb(0, 0, 255)),
..Default::default()
};
assert_eq!(solid_rgb(&resolve_vml_solid_fill(&common)), (0.0, 0.0, 1.0));
}
#[test]
fn vml_solid_fill_without_a_color_falls_back_to_the_attribute() {
let common = model::VmlCommonAttrs {
fill_color: Some(VmlColor::Rgb(255, 0, 0)),
fill: Some(VmlFill {
fill_type: VmlFillType::Solid,
color: None,
..Default::default()
}),
..Default::default()
};
assert_eq!(solid_rgb(&resolve_vml_solid_fill(&common)), (1.0, 0.0, 0.0));
}
#[test]
fn vml_non_solid_fills_degrade_to_no_fill_without_falling_back() {
for fill_type in [
VmlFillType::Gradient,
VmlFillType::GradientRadial,
VmlFillType::Tile,
VmlFillType::Frame,
VmlFillType::Pattern,
] {
let common = model::VmlCommonAttrs {
fill_color: Some(VmlColor::Rgb(255, 0, 0)),
fill: Some(VmlFill {
fill_type,
color: Some(VmlColor::Rgb(0, 255, 0)),
..Default::default()
}),
..Default::default()
};
assert!(
matches!(resolve_vml_solid_fill(&common), ResolvedFill::None),
"{fill_type:?} must not paint",
);
}
}
#[test]
fn vml_named_colors_are_not_resolved_yet() {
let common = model::VmlCommonAttrs {
fill_color: Some(VmlColor::Named(VmlNamedColor::Black)),
..Default::default()
};
assert!(matches!(
resolve_vml_solid_fill(&common),
ResolvedFill::None
));
}
fn vml_pt(value: f64) -> VmlLength {
VmlLength {
value,
unit: VmlLengthUnit::Pt,
}
}
fn vml_rect(x: f64, y: f64, w: f64, h: f64) -> model::VmlCommonAttrs {
model::VmlCommonAttrs {
style: model::VmlStyle {
position: Some(crate::model::CssPosition::Absolute),
margin_left: Some(vml_pt(x)),
margin_top: Some(vml_pt(y)),
width: Some(vml_pt(w)),
height: Some(vml_pt(h)),
..Default::default()
},
..Default::default()
}
}
#[test]
fn vml_rect_is_a_closed_rectangle_in_shape_local_points() {
use crate::render::resolve::shape_geometry::PathVerb;
let shape = build_vml_rect_shape(
&vml_rect(30.0, 40.0, 200.0, 10.0),
&default_state(),
AnchorFrame::Page,
)
.expect("a positioned, sized rect builds");
assert_x(
shape.x.resolve(PageParity::Odd).raw(),
30.0,
"page-frame x is the style's margin-left",
);
let FloatingImageY::RelativeToParagraph(y) = shape.y else {
panic!("VML rects anchor to the host paragraph");
};
assert_x(y.raw(), 40.0, "y is the style's margin-top");
assert_x(shape.size.width.raw(), 200.0, "width");
assert_x(shape.size.height.raw(), 10.0, "height");
let [sub] = &shape.paths[..] else {
panic!("one sub-path, got {}", shape.paths.len());
};
assert_eq!(sub.verbs.len(), 5, "4 corners + close");
assert!(matches!(sub.verbs[0], PathVerb::MoveTo(o) if o.x == Pt::ZERO && o.y == Pt::ZERO));
assert!(matches!(sub.verbs[4], PathVerb::Close));
}
#[test]
fn stack_frame_vml_rect_backs_out_the_left_margin() {
let shape = build_vml_rect_shape(
&vml_rect(80.0, 0.0, 10.0, 10.0),
&default_state(),
AnchorFrame::Stack,
)
.expect("a positioned, sized rect builds");
assert_eq!(
shape.x,
FloatingImageX::Absolute(Pt::new(8.0)),
"80pt page x minus the 72pt margin"
);
}
#[test]
fn vml_rect_needs_absolute_positioning_and_a_positive_extent() {
let mut unpositioned = vml_rect(30.0, 40.0, 200.0, 10.0);
unpositioned.style.position = None;
assert!(
build_vml_rect_shape(&unpositioned, &default_state(), AnchorFrame::Page).is_none(),
"no position:absolute"
);
for (w, h) in [(0.0, 10.0), (200.0, 0.0), (-5.0, 10.0)] {
assert!(
build_vml_rect_shape(
&vml_rect(30.0, 40.0, w, h),
&default_state(),
AnchorFrame::Page
)
.is_none(),
"{w} x {h} has no drawable area"
);
}
}
#[test]
fn vml_rect_carries_the_stroked_flag_onto_its_path() {
for stroked in [None, Some(false), Some(true)] {
let mut common = vml_rect(0.0, 0.0, 10.0, 10.0);
common.stroked = stroked;
let shape =
build_vml_rect_shape(&common, &default_state(), AnchorFrame::Page).expect("builds");
assert_eq!(
shape.paths[0].stroked,
stroked == Some(true),
"stroked={stroked:?}"
);
}
}
}