Compare commits

...

8 Commits

Author SHA1 Message Date
Ivica Nakić
87cb8f5094
Adding Croatian translations entries (#6413) 2025-06-23 15:09:03 +00:00
Wannes Malfait
38dd6da237
Fix stroke cap of shapes with partial stroke (#5688) 2025-06-23 14:58:04 +00:00
Laurenz
bf8ef2a4a5 Properly handle raw text elements 2025-06-23 15:59:22 +02:00
Laurenz
c2e2fd99f6 Extract write_children function 2025-06-23 15:56:01 +02:00
Laurenz
f8dc1ad3bd Handle pre elements that start with a newline 2025-06-23 15:56:01 +02:00
Laurenz
9050ee1639 Turn non-empty void element into export error 2025-06-23 14:22:09 +02:00
Laurenz
c1b2aee1a9 Test runner support for HTML export errors 2025-06-23 14:21:35 +02:00
Laurenz
fbb02f40d9 Consistent codepoint formatting in HTML and PDF error messages 2025-06-23 14:18:41 +02:00
14 changed files with 459 additions and 70 deletions

View File

@ -2,7 +2,9 @@ use std::fmt::Write;
use typst_library::diag::{bail, At, SourceResult, StrResult};
use typst_library::foundations::Repr;
use typst_library::html::{charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag};
use typst_library::html::{
attr, charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag,
};
use typst_library::layout::Frame;
use typst_syntax::Span;
@ -28,7 +30,7 @@ struct Writer {
pretty: bool,
}
/// Write a newline and indent, if pretty printing is enabled.
/// Writes a newline and indent, if pretty printing is enabled.
fn write_indent(w: &mut Writer) {
if w.pretty {
w.buf.push('\n');
@ -38,7 +40,7 @@ fn write_indent(w: &mut Writer) {
}
}
/// Encode an HTML node into the writer.
/// Encodes an HTML node into the writer.
fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> {
match node {
HtmlNode::Tag(_) => {}
@ -49,7 +51,7 @@ fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> {
Ok(())
}
/// Encode plain text into the writer.
/// Encodes plain text into the writer.
fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> {
for c in text.chars() {
if charsets::is_valid_in_normal_element_text(c) {
@ -61,7 +63,7 @@ fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> {
Ok(())
}
/// Encode one element into the write.
/// Encodes one element into the writer.
fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> {
w.buf.push('<');
w.buf.push_str(&element.tag.resolve());
@ -89,11 +91,33 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> {
w.buf.push('>');
if tag::is_void(element.tag) {
if !element.children.is_empty() {
bail!(element.span, "HTML void elements must not have children");
}
return Ok(());
}
if tag::is_raw(element.tag) {
write_raw(w, element)?;
} else if !element.children.is_empty() {
write_children(w, element)?;
}
w.buf.push_str("</");
w.buf.push_str(&element.tag.resolve());
w.buf.push('>');
Ok(())
}
/// Encodes the children of an element.
fn write_children(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> {
// See HTML spec § 13.1.2.5.
if element.tag == tag::pre && starts_with_newline(element) {
w.buf.push('\n');
}
let pretty = w.pretty;
if !element.children.is_empty() {
let pretty_inside = allows_pretty_inside(element.tag)
&& element.children.iter().any(|node| match node {
HtmlNode::Element(child) => wants_pretty_around(child.tag),
@ -120,16 +144,125 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> {
w.level -= 1;
write_indent(w);
}
w.pretty = pretty;
w.buf.push_str("</");
w.buf.push_str(&element.tag.resolve());
w.buf.push('>');
Ok(())
}
/// Whether the first character in the element is a newline.
fn starts_with_newline(element: &HtmlElement) -> bool {
for child in &element.children {
match child {
HtmlNode::Tag(_) => {}
HtmlNode::Text(text, _) => return text.starts_with(['\n', '\r']),
_ => return false,
}
}
false
}
/// Encodes the contents of a raw text element.
fn write_raw(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> {
let text = collect_raw_text(element)?;
if let Some(closing) = find_closing_tag(&text, element.tag) {
bail!(
element.span,
"HTML raw text element cannot contain its own closing tag";
hint: "the sequence `{closing}` appears in the raw text",
)
}
let mode = if w.pretty { RawMode::of(element, &text) } else { RawMode::Keep };
match mode {
RawMode::Keep => {
w.buf.push_str(&text);
}
RawMode::Wrap => {
w.buf.push('\n');
w.buf.push_str(&text);
write_indent(w);
}
RawMode::Indent => {
w.level += 1;
for line in text.lines() {
write_indent(w);
w.buf.push_str(line);
}
w.level -= 1;
write_indent(w);
}
}
Ok(())
}
/// Collects the textual contents of a raw text element.
fn collect_raw_text(element: &HtmlElement) -> SourceResult<String> {
let mut output = String::new();
for c in &element.children {
match c {
HtmlNode::Tag(_) => continue,
HtmlNode::Text(text, _) => output.push_str(text),
HtmlNode::Element(_) | HtmlNode::Frame(_) => {
let span = match c {
HtmlNode::Element(child) => child.span,
_ => element.span,
};
bail!(span, "HTML raw text element cannot have non-text children")
}
};
}
Ok(output)
}
/// Finds a closing sequence for the given tag in the text, if it exists.
///
/// See HTML spec § 13.1.2.6.
fn find_closing_tag(text: &str, tag: HtmlTag) -> Option<&str> {
let s = tag.resolve();
let len = s.len();
text.match_indices("</").find_map(|(i, _)| {
let rest = &text[i + 2..];
let disallowed = rest.len() >= len
&& rest[..len].eq_ignore_ascii_case(&s)
&& rest[len..].starts_with(['\t', '\n', '\u{c}', '\r', ' ', '>', '/']);
disallowed.then(|| &text[i..i + 2 + len])
})
}
/// How to format the contents of a raw text element.
enum RawMode {
/// Just don't touch it.
Keep,
/// Newline after the opening and newline + indent before the closing tag.
Wrap,
/// Newlines after opening and before closing tag and each line indented.
Indent,
}
impl RawMode {
fn of(element: &HtmlElement, text: &str) -> Self {
match element.tag {
tag::script
if !element.attrs.0.iter().any(|(attr, value)| {
*attr == attr::r#type && value != "text/javascript"
}) =>
{
// Template literals can be multi-line, so indent may change
// the semantics of the JavaScript.
if text.contains('`') {
Self::Wrap
} else {
Self::Indent
}
}
tag::style => Self::Indent,
_ => Self::Keep,
}
}
}
/// Whether we are allowed to add an extra newline at the start and end of the
/// element's contents.
///
@ -165,7 +298,7 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> {
c if charsets::is_w3c_text_char(c) && c != '\r' => {
write!(w.buf, "&#x{:x};", c as u32).unwrap()
}
_ => bail!("the character {} cannot be encoded in HTML", c.repr()),
_ => bail!("the character `{}` cannot be encoded in HTML", c.repr()),
}
Ok(())
}

View File

@ -180,9 +180,6 @@ fn handle(
if let Some(body) = elem.body(styles) {
children = html_fragment(engine, body, locator.next(&elem.span()), styles)?;
}
if tag::is_void(elem.tag) && !children.is_empty() {
bail!(elem.span(), "HTML void elements may not have children");
}
let element = HtmlElement {
tag: elem.tag,
attrs: elem.attrs(styles).clone(),

View File

@ -11,8 +11,8 @@ use typst_library::layout::{
};
use typst_library::visualize::{
CircleElem, CloseMode, Curve, CurveComponent, CurveElem, EllipseElem, FillRule,
FixedStroke, Geometry, LineElem, Paint, PathElem, PathVertex, PolygonElem, RectElem,
Shape, SquareElem, Stroke,
FixedStroke, Geometry, LineCap, LineElem, Paint, PathElem, PathVertex, PolygonElem,
RectElem, Shape, SquareElem, Stroke,
};
use typst_syntax::Span;
use typst_utils::{Get, Numeric};
@ -889,7 +889,13 @@ fn segmented_rect(
let end = current;
last = current;
let Some(stroke) = strokes.get_ref(start.side_cw()) else { continue };
let (shape, ontop) = segment(start, end, &corners, stroke);
let start_cap = stroke.cap;
let end_cap = match strokes.get_ref(end.side_ccw()) {
Some(stroke) => stroke.cap,
None => start_cap,
};
let (shape, ontop) =
segment(start, end, start_cap, end_cap, &corners, stroke);
if ontop {
res.push(shape);
} else {
@ -899,7 +905,14 @@ fn segmented_rect(
}
} else if let Some(stroke) = &strokes.top {
// single segment
let (shape, _) = segment(Corner::TopLeft, Corner::TopLeft, &corners, stroke);
let (shape, _) = segment(
Corner::TopLeft,
Corner::TopLeft,
stroke.cap,
stroke.cap,
&corners,
stroke,
);
res.push(shape);
}
res
@ -946,6 +959,8 @@ fn curve_segment(
fn segment(
start: Corner,
end: Corner,
start_cap: LineCap,
end_cap: LineCap,
corners: &Corners<ControlPoints>,
stroke: &FixedStroke,
) -> (Shape, bool) {
@ -979,7 +994,7 @@ fn segment(
let use_fill = solid && fill_corners(start, end, corners);
let shape = if use_fill {
fill_segment(start, end, corners, stroke)
fill_segment(start, end, start_cap, end_cap, corners, stroke)
} else {
stroke_segment(start, end, corners, stroke.clone())
};
@ -1010,6 +1025,8 @@ fn stroke_segment(
fn fill_segment(
start: Corner,
end: Corner,
start_cap: LineCap,
end_cap: LineCap,
corners: &Corners<ControlPoints>,
stroke: &FixedStroke,
) -> Shape {
@ -1035,8 +1052,7 @@ fn fill_segment(
if c.arc_outer() {
curve.arc_line(c.mid_outer(), c.center_outer(), c.end_outer());
} else {
curve.line(c.outer());
curve.line(c.end_outer());
c.start_cap(&mut curve, start_cap);
}
}
@ -1079,7 +1095,7 @@ fn fill_segment(
if c.arc_inner() {
curve.arc_line(c.mid_inner(), c.center_inner(), c.start_inner());
} else {
curve.line(c.center_inner());
c.end_cap(&mut curve, end_cap);
}
}
@ -1134,6 +1150,16 @@ struct ControlPoints {
}
impl ControlPoints {
/// Rotate point around the origin, relative to the top-left.
fn rotate_centered(&self, point: Point) -> Point {
match self.corner {
Corner::TopLeft => point,
Corner::TopRight => Point { x: -point.y, y: point.x },
Corner::BottomRight => Point { x: -point.x, y: -point.y },
Corner::BottomLeft => Point { x: point.y, y: -point.x },
}
}
/// Move and rotate the point from top-left to the required corner.
fn rotate(&self, point: Point) -> Point {
match self.corner {
@ -1280,6 +1306,77 @@ impl ControlPoints {
y: self.stroke_after,
})
}
/// Draw the cap at the beginning of the segment.
///
/// If this corner has a stroke before it,
/// a default "butt" cap is used.
///
/// NOTE: doesn't support the case where the corner has a radius.
pub fn start_cap(&self, curve: &mut Curve, cap_type: LineCap) {
if self.stroke_before != Abs::zero()
|| self.radius != Abs::zero()
|| cap_type == LineCap::Butt
{
// Just the default cap.
curve.line(self.outer());
} else if cap_type == LineCap::Square {
// Extend by the stroke width.
let offset =
self.rotate_centered(Point { x: -self.stroke_after, y: Abs::zero() });
curve.line(self.end_inner() + offset);
curve.line(self.outer() + offset);
} else if cap_type == LineCap::Round {
// We push the center by a little bit to ensure the correct
// half of the circle gets drawn. If it is perfectly centered
// the `arc` function just degenerates into a line, which we
// do not want in this case.
curve.arc(
self.end_inner(),
(self.end_inner()
+ self.rotate_centered(Point { x: Abs::raw(1.0), y: Abs::zero() })
+ self.outer())
/ 2.,
self.outer(),
);
}
curve.line(self.end_outer());
}
/// Draw the cap at the end of the segment.
///
/// If this corner has a stroke before it,
/// a default "butt" cap is used.
///
/// NOTE: doesn't support the case where the corner has a radius.
pub fn end_cap(&self, curve: &mut Curve, cap_type: LineCap) {
if self.stroke_after != Abs::zero()
|| self.radius != Abs::zero()
|| cap_type == LineCap::Butt
{
// Just the default cap.
curve.line(self.center_inner());
} else if cap_type == LineCap::Square {
// Extend by the stroke width.
let offset =
self.rotate_centered(Point { x: Abs::zero(), y: -self.stroke_before });
curve.line(self.outer() + offset);
curve.line(self.center_inner() + offset);
} else if cap_type == LineCap::Round {
// We push the center by a little bit to ensure the correct
// half of the circle gets drawn. If it is perfectly centered
// the `arc` function just degenerates into a line, which we
// do not want in this case.
curve.arc(
self.outer(),
(self.outer()
+ self.rotate_centered(Point { x: Abs::zero(), y: Abs::raw(1.0) })
+ self.center_inner())
/ 2.,
self.center_inner(),
);
}
}
}
/// Helper to draw arcs with Bézier curves.

View File

@ -30,6 +30,7 @@ const TRANSLATIONS: &[(&str, &str)] = &[
translation!("fr"),
translation!("gl"),
translation!("he"),
translation!("hr"),
translation!("hu"),
translation!("id"),
translation!("is"),

View File

@ -0,0 +1,8 @@
figure = Slika
table = Tablica
equation = Jednadžba
bibliography = Literatura
heading = Odjeljak
outline = Sadržaj
raw = Kôd
page = str.

View File

@ -13,7 +13,7 @@ use krilla::surface::Surface;
use krilla::{Document, SerializeSettings};
use krilla_svg::render_svg_glyph;
use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult};
use typst_library::foundations::NativeElement;
use typst_library::foundations::{NativeElement, Repr};
use typst_library::introspection::Location;
use typst_library::layout::{
Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform,
@ -429,14 +429,18 @@ fn convert_error(
display_font(gc.fonts_backward.get(f).unwrap());
hint: "try using a different font"
),
ValidationError::InvalidCodepointMapping(_, _, cp, loc) => {
if let Some(c) = cp.map(|c| eco_format!("{:#06x}", c as u32)) {
ValidationError::InvalidCodepointMapping(_, _, c, loc) => {
if let Some(c) = c {
let msg = if loc.is_some() {
"the PDF contains text with"
} else {
"the text contains"
};
error!(to_span(*loc), "{prefix} {msg} the disallowed codepoint {c}")
error!(
to_span(*loc),
"{prefix} {msg} the disallowed codepoint `{}`",
c.repr()
)
} else {
// I think this code path is in theory unreachable,
// but just to be safe.
@ -454,13 +458,12 @@ fn convert_error(
}
}
ValidationError::UnicodePrivateArea(_, _, c, loc) => {
let code_point = eco_format!("{:#06x}", *c as u32);
let msg = if loc.is_some() { "the PDF" } else { "the text" };
error!(
to_span(*loc),
"{prefix} {msg} contains the codepoint {code_point}";
"{prefix} {msg} contains the codepoint `{}`", c.repr();
hint: "codepoints from the Unicode private area are \
forbidden in this export mode"
forbidden in this export mode",
)
}
ValidationError::Transparency(loc) => {

View File

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><textarea>hello &lt;/textarea></textarea></body>
</html>

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<pre>hello</pre>
<pre>
hello</pre>
<pre>
hello</pre>
</body>
</html>

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script>
const x = 1
const y = 2
console.log(x < y, Math.max(1, 2))
</script>
<script>
console.log(`Hello
World`)
</script>
<script type="text/python">x = 1
y = 2
print(x < y, max(x, y))</script>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<style>
body {
text: red;
}
</style>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

View File

@ -4,7 +4,7 @@ use std::path::PathBuf;
use ecow::eco_vec;
use tiny_skia as sk;
use typst::diag::{SourceDiagnostic, Warned};
use typst::diag::{SourceDiagnostic, SourceResult, Warned};
use typst::html::HtmlDocument;
use typst::layout::{Abs, Frame, FrameItem, PagedDocument, Transform};
use typst::visualize::Color;
@ -82,17 +82,26 @@ impl<'a> Runner<'a> {
/// Run test specific to document format.
fn run_test<D: OutputType>(&mut self) {
let Warned { output, warnings } = typst::compile(&self.world);
let (doc, errors) = match output {
let (doc, mut errors) = match output {
Ok(doc) => (Some(doc), eco_vec![]),
Err(errors) => (None, errors),
};
if doc.is_none() && errors.is_empty() {
D::check_custom(self, doc.as_ref());
let output = doc.and_then(|doc: D| match doc.make_live() {
Ok(live) => Some((doc, live)),
Err(list) => {
errors.extend(list);
None
}
});
if output.is_none() && errors.is_empty() {
log!(self, "no document, but also no errors");
}
D::check_custom(self, doc.as_ref());
self.check_output(doc.as_ref());
self.check_output(output);
for error in &errors {
self.check_diagnostic(NoteKind::Error, error);
@ -128,12 +137,12 @@ impl<'a> Runner<'a> {
}
/// Check that the document output is correct.
fn check_output<D: OutputType>(&mut self, document: Option<&D>) {
fn check_output<D: OutputType>(&mut self, output: Option<(D, D::Live)>) {
let live_path = D::live_path(&self.test.name);
let ref_path = D::ref_path(&self.test.name);
let ref_data = std::fs::read(&ref_path);
let Some(document) = document else {
let Some((document, live)) = output else {
if ref_data.is_ok() {
log!(self, "missing document");
log!(self, " ref | {}", ref_path.display());
@ -141,7 +150,7 @@ impl<'a> Runner<'a> {
return;
};
let skippable = match D::is_skippable(document) {
let skippable = match D::is_skippable(&document) {
Ok(skippable) => skippable,
Err(()) => {
log!(self, "document has zero pages");
@ -157,7 +166,6 @@ impl<'a> Runner<'a> {
}
// Render and save live version.
let live = document.make_live();
document.save_live(&self.test.name, &live);
// Compare against reference output if available.
@ -214,9 +222,13 @@ impl<'a> Runner<'a> {
return;
}
let message = diag.message.replace("\\", "/");
let message = if diag.message.contains("\\u{") {
&diag.message
} else {
&diag.message.replace("\\", "/")
};
let range = self.world.range(diag.span);
self.validate_note(kind, diag.span.id(), range.clone(), &message);
self.validate_note(kind, diag.span.id(), range.clone(), message);
// Check hints.
for hint in &diag.hints {
@ -359,7 +371,7 @@ trait OutputType: Document {
}
/// Produces the live output.
fn make_live(&self) -> Self::Live;
fn make_live(&self) -> SourceResult<Self::Live>;
/// Saves the live output.
fn save_live(&self, name: &str, live: &Self::Live);
@ -406,8 +418,8 @@ impl OutputType for PagedDocument {
}
}
fn make_live(&self) -> Self::Live {
render(self, 1.0)
fn make_live(&self) -> SourceResult<Self::Live> {
Ok(render(self, 1.0))
}
fn save_live(&self, name: &str, live: &Self::Live) {
@ -471,9 +483,8 @@ impl OutputType for HtmlDocument {
format!("{}/html/{}.html", crate::REF_PATH, name).into()
}
fn make_live(&self) -> Self::Live {
// TODO: Do this earlier to be able to process export errors.
typst_html::html(self).unwrap()
fn make_live(&self) -> SourceResult<Self::Live> {
typst_html::html(self)
}
fn save_live(&self, name: &str, live: &Self::Live) {

View File

@ -0,0 +1,63 @@
--- html-non-char html ---
// Error: 1-9 the character `"\u{fdd0}"` cannot be encoded in HTML
\u{fdd0}
--- html-void-element-with-children html ---
// Error: 2-27 HTML void elements must not have children
#html.elem("img", [Hello])
--- html-pre-starting-with-newline html ---
#html.pre("hello")
#html.pre("\nhello")
#html.pre("\n\nhello")
--- html-script html ---
// This should be pretty and indented.
#html.script(
```js
const x = 1
const y = 2
console.log(x < y, Math.max(1, 2))
```.text,
)
// This should have extra newlines, but no indent because of the multiline
// string literal.
#html.script("console.log(`Hello\nWorld`)")
// This should be untouched.
#html.script(
type: "text/python",
```py
x = 1
y = 2
print(x < y, max(x, y))
```.text,
)
--- html-style html ---
// This should be pretty and indented.
#html.style(
```css
body {
text: red;
}
```.text,
)
--- html-raw-text-contains-elem html ---
// Error: 14-32 HTML raw text element cannot have non-text children
#html.script(html.strong[Hello])
--- html-raw-text-contains-frame html ---
// Error: 2-29 HTML raw text element cannot have non-text children
#html.script(html.frame[Ok])
--- html-raw-text-contains-closing-tag html ---
// Error: 2-32 HTML raw text element cannot contain its own closing tag
// Hint: 2-32 the sequence `</SCRiPT` appears in the raw text
#html.script("hello </SCRiPT ")
--- html-escapable-raw-text-contains-closing-tag html ---
// This is okay because we escape it.
#html.textarea("hello </textarea>")

View File

@ -54,6 +54,22 @@
#v(3pt)
#rect(width: 20pt, height: 20pt, stroke: (thickness: 5pt, join: "round"))
--- rect-stroke-caps ---
// Separated segments
#rect(width: 20pt, height: 20pt, stroke: (
left: (cap: "round", thickness: 5pt),
right: (cap: "square", thickness: 7pt),
))
// Joined segment with different caps.
#rect(width: 20pt, height: 20pt, stroke: (
left: (cap: "round", thickness: 5pt),
top: (cap: "square", thickness: 7pt),
))
// No caps when there is a radius for that corner.
#rect(width: 20pt, height: 20pt, radius: (top: 3pt), stroke: (
left: (cap: "round", thickness: 5pt),
top: (cap: "square", thickness: 7pt),
))
--- red-stroke-bad-type ---
// Error: 15-21 expected length, color, gradient, tiling, dictionary, stroke, none, or auto, found array
#rect(stroke: (1, 2))