Compare commits

...

7 Commits

Author SHA1 Message Date
Eric Biedert
011d4e2909
Merge bef4e20434334d450a9d3cf3a41ada9c6cde1535 into 9a6268050fb769e18c4889fa5f59d4150e8878d6 2025-07-15 22:48:16 -03:00
Laurenz
9a6268050f
HTML frame improvements (#6605) 2025-07-15 14:48:31 +00:00
Eric Biedert
bef4e20434 Add test for location of migrated block
Previously, this would result in a position on the first page.
2025-05-28 13:03:17 +02:00
Eric Biedert
811996eb70 Update references of existing tests
In `grid-header-containing-rowspan`, the first region is now correctly
not stroked.

Not sure what happened in `grid-header-orphan-prevention`, but the "B"
in the first header was too bold before.
2025-05-27 15:27:04 +02:00
Eric Biedert
02f07e7912 Don't label empty orphan frames
Adding a label makes a previously empty frame non-empty, but we want to
keep orphans empty.
2025-05-27 15:27:04 +02:00
Eric Biedert
693edb475d Don't break blocks after empty frame
Instead, spill the whole child into the next region to prevent small
leftovers to influence layout. This is not done when all frames are
empty (e.g. for an explicitly sized block without content or fill).

This helps with the following cases:
- Previously, if a sticky block was followed by a leftover frame, the
  stickiness would be ignored, as the leftover was in fact sticking.
  This is not currently a problem, as sticky blocks aren't really
  breakable at the moment, but probably will be in the future.
- When ignoring stroke and fill for a first empty frame, a nested broken
  block would previously make the first frame not be considered empty
  anymore, which would lead to the leftover frame being filled.
- Similarly, when the fill of an explicitly sized block is ignored in
  the first empty frame, the leftover part would still be considered as
  laid out, making the actually visible block too small.
2025-05-27 15:21:15 +02:00
Eric Biedert
606183cd30 Add tests 2025-05-27 15:21:15 +02:00
19 changed files with 134 additions and 26 deletions

View File

@ -109,10 +109,7 @@ fn handle(
styles.chain(&style),
Region::new(Size::splat(Abs::inf()), Axes::splat(false)),
)?;
output.push(HtmlNode::Frame(HtmlFrame {
inner: frame,
text_size: styles.resolve(TextElem::size),
}));
output.push(HtmlNode::Frame(HtmlFrame::new(frame, styles)));
} else {
engine.sink.warn(warning!(
child.span(),

View File

@ -2,10 +2,11 @@ use std::fmt::{self, Debug, Display, Formatter};
use ecow::{EcoString, EcoVec};
use typst_library::diag::{bail, HintedStrResult, StrResult};
use typst_library::foundations::{cast, Dict, Repr, Str};
use typst_library::foundations::{cast, Dict, Repr, Str, StyleChain};
use typst_library::introspection::{Introspector, Tag};
use typst_library::layout::{Abs, Frame};
use typst_library::model::DocumentInfo;
use typst_library::text::TextElem;
use typst_syntax::Span;
use typst_utils::{PicoStr, ResolvedPicoStr};
@ -279,3 +280,10 @@ pub struct HtmlFrame {
/// consistently.
pub text_size: Abs,
}
impl HtmlFrame {
/// Wraps a laid-out frame.
pub fn new(inner: Frame, styles: StyleChain) -> Self {
Self { inner, text_size: styles.resolve(TextElem::size) }
}
}

View File

@ -121,6 +121,7 @@ fn write_children(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> {
let pretty_inside = allows_pretty_inside(element.tag)
&& element.children.iter().any(|node| match node {
HtmlNode::Element(child) => wants_pretty_around(child.tag),
HtmlNode::Frame(_) => true,
_ => false,
});
@ -305,14 +306,6 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> {
/// Encode a laid out frame into the writer.
fn write_frame(w: &mut Writer, frame: &HtmlFrame) {
// FIXME: This string replacement is obviously a hack.
let svg = typst_svg::svg_frame(&frame.inner).replace(
"<svg class",
&format!(
"<svg style=\"overflow: visible; width: {}em; height: {}em;\" class",
frame.inner.width() / frame.text_size,
frame.inner.height() / frame.text_size,
),
);
let svg = typst_svg::svg_html_frame(&frame.inner, frame.text_size);
w.buf.push_str(&svg);
}

View File

@ -206,13 +206,11 @@ pub fn layout_multi_block(
let has_inset = !inset.is_zero();
let is_explicit = matches!(body, None | Some(BlockBody::Content(_)));
// Skip filling/stroking the first frame if it is empty and a non-empty
// one follows.
// Skip filling, stroking and labeling the first frame if it is empty and
// a non-empty one follows.
let mut skip_first = false;
if let [first, rest @ ..] = fragment.as_slice() {
skip_first = has_fill_or_stroke
&& first.is_empty()
&& rest.iter().any(|frame| !frame.is_empty());
skip_first = first.is_empty() && rest.iter().any(|frame| !frame.is_empty());
}
// Post-process to apply insets, clipping, fills, and strokes.
@ -244,7 +242,8 @@ pub fn layout_multi_block(
// Assign label to each frame in the fragment.
if let Some(label) = elem.label() {
for frame in fragment.iter_mut() {
// Skip empty orphan frames, as a label would make them non-empty.
for frame in fragment.iter_mut().skip(if skip_first { 1 } else { 0 }) {
frame.label(label);
}
}

View File

@ -459,6 +459,7 @@ impl<'a> MultiChild<'a> {
regions: Regions,
) -> SourceResult<(Frame, Option<MultiSpill<'a, 'b>>)> {
let fragment = self.layout_full(engine, regions)?;
let exist_non_empty_frame = fragment.iter().any(|f| !f.is_empty());
// Extract the first frame.
let mut frames = fragment.into_iter();
@ -468,6 +469,7 @@ impl<'a> MultiChild<'a> {
let mut spill = None;
if frames.next().is_some() {
spill = Some(MultiSpill {
exist_non_empty_frame,
multi: self,
full: regions.full,
first: regions.size.y,
@ -539,6 +541,7 @@ fn layout_multi_impl(
/// The spilled remains of a `MultiChild` that broke across two regions.
#[derive(Debug, Clone)]
pub struct MultiSpill<'a, 'b> {
pub(super) exist_non_empty_frame: bool,
multi: &'b MultiChild<'a>,
first: Abs,
full: Abs,

View File

@ -283,6 +283,13 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> {
// Lay out the block.
let (frame, spill) = multi.layout(self.composer.engine, self.regions)?;
if frame.is_empty() && spill.as_ref().is_some_and(|s| s.exist_non_empty_frame) {
// If the first frame is empty, but there are non-empty frames in
// the spill, the whole child should be put in the next region to
// avoid any invisible orphans at the end of this region.
return Err(Stop::Finish(false));
}
self.frame(frame, multi.align, multi.sticky, true)?;
// If the block didn't fully fit into the current region, save it into

View File

@ -45,6 +45,30 @@ pub fn svg_frame(frame: &Frame) -> String {
renderer.finalize()
}
/// Export a frame into an SVG suitable for embedding into HTML.
#[typst_macros::time(name = "svg html frame")]
pub fn svg_html_frame(frame: &Frame, text_size: Abs) -> String {
let mut renderer = SVGRenderer::with_options(xmlwriter::Options {
indent: xmlwriter::Indent::None,
..Default::default()
});
renderer.write_header_with_custom_attrs(frame.size(), |xml| {
xml.write_attribute("class", "typst-frame");
xml.write_attribute_fmt(
"style",
format_args!(
"overflow: visible; width: {}em; height: {}em;",
frame.width() / text_size,
frame.height() / text_size,
),
);
});
let state = State::new(frame.size(), Transform::identity());
renderer.render_frame(state, Transform::identity(), frame);
renderer.finalize()
}
/// Export a document with potentially multiple pages into a single SVG file.
///
/// The padding will be added around and between the individual frames.
@ -158,8 +182,13 @@ impl State {
impl SVGRenderer {
/// Create a new SVG renderer with empty glyph and clip path.
fn new() -> Self {
Self::with_options(Default::default())
}
/// Create a new SVG renderer with the given configuration.
fn with_options(options: xmlwriter::Options) -> Self {
SVGRenderer {
xml: XmlWriter::new(xmlwriter::Options::default()),
xml: XmlWriter::new(options),
glyphs: Deduplicator::new('g'),
clip_paths: Deduplicator::new('c'),
gradient_refs: Deduplicator::new('g'),
@ -170,11 +199,22 @@ impl SVGRenderer {
}
}
/// Write the SVG header, including the `viewBox` and `width` and `height`
/// attributes.
/// Write the default SVG header, including a `typst-doc` class, the
/// `viewBox` and `width` and `height` attributes.
fn write_header(&mut self, size: Size) {
self.write_header_with_custom_attrs(size, |xml| {
xml.write_attribute("class", "typst-doc");
});
}
/// Write the SVG header with additional attributes and standard attributes.
fn write_header_with_custom_attrs(
&mut self,
size: Size,
write_custom_attrs: impl FnOnce(&mut XmlWriter),
) {
self.xml.start_element("svg");
self.xml.write_attribute("class", "typst-doc");
write_custom_attrs(&mut self.xml);
self.xml.write_attribute_fmt(
"viewBox",
format_args!("0 0 {} {}", size.x.to_pt(), size.y.to_pt()),

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p>A rectangle:</p>
<svg class="typst-frame" style="overflow: visible; width: 4.5em; height: 3em;" viewBox="0 0 45 30" width="45pt" height="30pt" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:h5="http://www.w3.org/1999/xhtml"><g><g transform="translate(-0 -0)"><path class="typst-shape" fill="none" stroke="#000000" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="4" d="M 0 0 L 0 30 L 45 30 L 45 0 Z "/></g></g></svg>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

View File

@ -1,5 +1,6 @@
// No proper HTML tests here yet because we don't want to test SVG export just
// yet. We'll definitely add tests at some point.
--- html-frame html ---
A rectangle:
#html.frame(rect())
--- html-frame-in-layout ---
// Ensure that HTML frames are transparent in layout. This is less important for

View File

@ -72,6 +72,18 @@ B
#pagebreak(weak: true)
#metadata(none) <e>
--- locate-migrated-breakable ---
// Ensure that when a breakable element fully migrates to the next page without
// orphan frames, its position correctly reflects that.
#set page(height: 40pt)
A
#block[B]<a>
#context test(
locate(<a>).position(),
(page: 2, x: 10pt, y: 10pt),
)
--- issue-4029-locate-after-spacing ---
#set page(margin: 10pt)
#show heading: it => v(40pt) + it

View File

@ -64,6 +64,12 @@ First!
is the sun.
]
--- block-multiple-pages-empty ---
#set page(height: 60pt)
A
#block(height: 30pt)
B
--- block-box-fill ---
#set page(height: 100pt)
#let words = lorem(18).split()
@ -287,6 +293,37 @@ Paragraph
#block(width: 100%, fill: red, box("a box"))
#block(width: 100%, fill: red, [#box("a box") #box()])
--- issue-2914-block-height-cut-off ---
// Ensure that breaking a block doesn't shrink its height.
#set page(height: 65pt)
#set block(fill: aqua, width: 25pt, height: 25pt, inset: 5pt)
#block[A]
#block[B]
--- issue-2914-block-fill-skip-nested ---
// Ensure that fill and stroke are skipped for an empty frame with a nested block.
#set page(height: 50pt)
A
#block(fill: aqua, stroke: blue, inset: 5pt, width: 100%, block[B])
--- issue-6304-block-skip-label ---
// Ensure that labeling is skipped for an empty orphan frame.
#set page(height: 60pt)
A
#block(sticky: true)[B]
#block[C] <label>
--- issue-6125-block-place-width-limited ---
// Ensure that the width of a placed block isn't limited by its siblings.
#set page(height: 70pt)
#let b = block({
square(size: 20pt, fill: aqua)
place(top, box(height: 10pt, width: 1fr, fill: blue))
})
#b
#b
--- issue-5296-block-sticky-in-block-at-top ---
#set page(height: 3cm)
#v(1.6cm)