From e9dc4bb20404037cf192c19f00a010ff3bb1a10b Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 11:12:58 +0200 Subject: [PATCH] Typed HTML API (#6476) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/typst-ide/src/complete.rs | 9 + crates/typst-ide/src/tests.rs | 6 +- crates/typst-library/src/foundations/float.rs | 22 + crates/typst-library/src/html/dom.rs | 460 ++++++---- crates/typst-library/src/html/mod.rs | 2 + crates/typst-library/src/html/typed.rs | 868 ++++++++++++++++++ crates/typst-utils/src/pico.rs | 52 ++ tests/ref/html/html-typed.html | 63 ++ tests/suite/html/typed.typ | 187 ++++ 11 files changed, 1513 insertions(+), 160 deletions(-) create mode 100644 crates/typst-library/src/html/typed.rs create mode 100644 tests/ref/html/html-typed.html create mode 100644 tests/suite/html/typed.typ diff --git a/Cargo.lock b/Cargo.lock index 218fa2e4d..58cac3c58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-assets?rev=c74e539#c74e539b090070a0c66fd007c550f5b6d3b724bd" +source = "git+https://github.com/typst/typst-assets?rev=c1089b4#c1089b46c461bdde579c55caa941a3cc7dec3e8a" [[package]] name = "typst-cli" diff --git a/Cargo.toml b/Cargo.toml index 03141cbbf..72ab9094d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ typst-svg = { path = "crates/typst-svg", version = "0.13.1" } typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } typst-timing = { path = "crates/typst-timing", version = "0.13.1" } typst-utils = { path = "crates/typst-utils", version = "0.13.1" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "c74e539" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "c1089b4" } typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } arrayvec = "0.7.4" az = "1.2" diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 47727743f..536423318 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -1848,4 +1848,13 @@ mod tests { .must_include(["\"New Computer Modern Math\""]) .must_exclude(["\"Libertinus Serif\""]); } + + #[test] + fn test_autocomplete_typed_html() { + test("#html.div(translate: )", -2) + .must_include(["true", "false"]) + .must_exclude(["\"yes\"", "\"no\""]); + test("#html.input(value: )", -2).must_include(["float", "string", "red", "blue"]); + test("#html.div(role: )", -2).must_include(["\"alertdialog\""]); + } } diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 5edc05f17..dd5c230ad 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -10,7 +10,7 @@ use typst::syntax::package::{PackageSpec, PackageVersion}; use typst::syntax::{FileId, Source, VirtualPath}; use typst::text::{Font, FontBook, TextElem, TextSize}; use typst::utils::{singleton, LazyHash}; -use typst::{Library, World}; +use typst::{Feature, Library, World}; use crate::IdeWorld; @@ -168,7 +168,9 @@ fn library() -> Library { // Set page width to 120pt with 10pt margins, so that the inner page is // exactly 100pt wide. Page height is unbounded and font size is 10pt so // that it multiplies to nice round numbers. - let mut lib = typst::Library::default(); + let mut lib = typst::Library::builder() + .with_features([Feature::Html].into_iter().collect()) + .build(); lib.styles .set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into()))); lib.styles.set(PageElem::set_height(Smart::Auto)); diff --git a/crates/typst-library/src/foundations/float.rs b/crates/typst-library/src/foundations/float.rs index 21d0a8d81..353e498d3 100644 --- a/crates/typst-library/src/foundations/float.rs +++ b/crates/typst-library/src/foundations/float.rs @@ -210,3 +210,25 @@ cast! { fn parse_float(s: EcoString) -> Result { s.replace(repr::MINUS_SIGN, "-").parse() } + +/// A floating-point number that must be positive (strictly larger than zero). +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)] +pub struct PositiveF64(f64); + +impl PositiveF64 { + /// Wrap a float if it is positive. + pub fn new(value: f64) -> Option { + (value > 0.0).then_some(Self(value)) + } + + /// Get the underlying value. + pub fn get(self) -> f64 { + self.0 + } +} + +cast! { + PositiveF64, + self => self.get().into_value(), + v: f64 => Self::new(v).ok_or("number must be positive")?, +} diff --git a/crates/typst-library/src/html/dom.rs b/crates/typst-library/src/html/dom.rs index 1b725d543..35d513c10 100644 --- a/crates/typst-library/src/html/dom.rs +++ b/crates/typst-library/src/html/dom.rs @@ -188,7 +188,7 @@ cast! { .collect::>()?), } -/// An attribute of an HTML. +/// An attribute of an HTML element. #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct HtmlAttr(PicoStr); @@ -347,135 +347,124 @@ pub mod charsets { } /// Predefined constants for HTML tags. +#[allow(non_upper_case_globals)] pub mod tag { use super::HtmlTag; - macro_rules! tags { - ($($tag:ident)*) => { - $(#[allow(non_upper_case_globals)] - pub const $tag: HtmlTag = HtmlTag::constant( - stringify!($tag) - );)* - } - } + pub const a: HtmlTag = HtmlTag::constant("a"); + pub const abbr: HtmlTag = HtmlTag::constant("abbr"); + pub const address: HtmlTag = HtmlTag::constant("address"); + pub const area: HtmlTag = HtmlTag::constant("area"); + pub const article: HtmlTag = HtmlTag::constant("article"); + pub const aside: HtmlTag = HtmlTag::constant("aside"); + pub const audio: HtmlTag = HtmlTag::constant("audio"); + pub const b: HtmlTag = HtmlTag::constant("b"); + pub const base: HtmlTag = HtmlTag::constant("base"); + pub const bdi: HtmlTag = HtmlTag::constant("bdi"); + pub const bdo: HtmlTag = HtmlTag::constant("bdo"); + pub const blockquote: HtmlTag = HtmlTag::constant("blockquote"); + pub const body: HtmlTag = HtmlTag::constant("body"); + pub const br: HtmlTag = HtmlTag::constant("br"); + pub const button: HtmlTag = HtmlTag::constant("button"); + pub const canvas: HtmlTag = HtmlTag::constant("canvas"); + pub const caption: HtmlTag = HtmlTag::constant("caption"); + pub const cite: HtmlTag = HtmlTag::constant("cite"); + pub const code: HtmlTag = HtmlTag::constant("code"); + pub const col: HtmlTag = HtmlTag::constant("col"); + pub const colgroup: HtmlTag = HtmlTag::constant("colgroup"); + pub const data: HtmlTag = HtmlTag::constant("data"); + pub const datalist: HtmlTag = HtmlTag::constant("datalist"); + pub const dd: HtmlTag = HtmlTag::constant("dd"); + pub const del: HtmlTag = HtmlTag::constant("del"); + pub const details: HtmlTag = HtmlTag::constant("details"); + pub const dfn: HtmlTag = HtmlTag::constant("dfn"); + pub const dialog: HtmlTag = HtmlTag::constant("dialog"); + pub const div: HtmlTag = HtmlTag::constant("div"); + pub const dl: HtmlTag = HtmlTag::constant("dl"); + pub const dt: HtmlTag = HtmlTag::constant("dt"); + pub const em: HtmlTag = HtmlTag::constant("em"); + pub const embed: HtmlTag = HtmlTag::constant("embed"); + pub const fieldset: HtmlTag = HtmlTag::constant("fieldset"); + pub const figcaption: HtmlTag = HtmlTag::constant("figcaption"); + pub const figure: HtmlTag = HtmlTag::constant("figure"); + pub const footer: HtmlTag = HtmlTag::constant("footer"); + pub const form: HtmlTag = HtmlTag::constant("form"); + pub const h1: HtmlTag = HtmlTag::constant("h1"); + pub const h2: HtmlTag = HtmlTag::constant("h2"); + pub const h3: HtmlTag = HtmlTag::constant("h3"); + pub const h4: HtmlTag = HtmlTag::constant("h4"); + pub const h5: HtmlTag = HtmlTag::constant("h5"); + pub const h6: HtmlTag = HtmlTag::constant("h6"); + pub const head: HtmlTag = HtmlTag::constant("head"); + pub const header: HtmlTag = HtmlTag::constant("header"); + pub const hgroup: HtmlTag = HtmlTag::constant("hgroup"); + pub const hr: HtmlTag = HtmlTag::constant("hr"); + pub const html: HtmlTag = HtmlTag::constant("html"); + pub const i: HtmlTag = HtmlTag::constant("i"); + pub const iframe: HtmlTag = HtmlTag::constant("iframe"); + pub const img: HtmlTag = HtmlTag::constant("img"); + pub const input: HtmlTag = HtmlTag::constant("input"); + pub const ins: HtmlTag = HtmlTag::constant("ins"); + pub const kbd: HtmlTag = HtmlTag::constant("kbd"); + pub const label: HtmlTag = HtmlTag::constant("label"); + pub const legend: HtmlTag = HtmlTag::constant("legend"); + pub const li: HtmlTag = HtmlTag::constant("li"); + pub const link: HtmlTag = HtmlTag::constant("link"); + pub const main: HtmlTag = HtmlTag::constant("main"); + pub const map: HtmlTag = HtmlTag::constant("map"); + pub const mark: HtmlTag = HtmlTag::constant("mark"); + pub const menu: HtmlTag = HtmlTag::constant("menu"); + pub const meta: HtmlTag = HtmlTag::constant("meta"); + pub const meter: HtmlTag = HtmlTag::constant("meter"); + pub const nav: HtmlTag = HtmlTag::constant("nav"); + pub const noscript: HtmlTag = HtmlTag::constant("noscript"); + pub const object: HtmlTag = HtmlTag::constant("object"); + pub const ol: HtmlTag = HtmlTag::constant("ol"); + pub const optgroup: HtmlTag = HtmlTag::constant("optgroup"); + pub const option: HtmlTag = HtmlTag::constant("option"); + pub const output: HtmlTag = HtmlTag::constant("output"); + pub const p: HtmlTag = HtmlTag::constant("p"); + pub const picture: HtmlTag = HtmlTag::constant("picture"); + pub const pre: HtmlTag = HtmlTag::constant("pre"); + pub const progress: HtmlTag = HtmlTag::constant("progress"); + pub const q: HtmlTag = HtmlTag::constant("q"); + pub const rp: HtmlTag = HtmlTag::constant("rp"); + pub const rt: HtmlTag = HtmlTag::constant("rt"); + pub const ruby: HtmlTag = HtmlTag::constant("ruby"); + pub const s: HtmlTag = HtmlTag::constant("s"); + pub const samp: HtmlTag = HtmlTag::constant("samp"); + pub const script: HtmlTag = HtmlTag::constant("script"); + pub const search: HtmlTag = HtmlTag::constant("search"); + pub const section: HtmlTag = HtmlTag::constant("section"); + pub const select: HtmlTag = HtmlTag::constant("select"); + pub const slot: HtmlTag = HtmlTag::constant("slot"); + pub const small: HtmlTag = HtmlTag::constant("small"); + pub const source: HtmlTag = HtmlTag::constant("source"); + pub const span: HtmlTag = HtmlTag::constant("span"); + pub const strong: HtmlTag = HtmlTag::constant("strong"); + pub const style: HtmlTag = HtmlTag::constant("style"); + pub const sub: HtmlTag = HtmlTag::constant("sub"); + pub const summary: HtmlTag = HtmlTag::constant("summary"); + pub const sup: HtmlTag = HtmlTag::constant("sup"); + pub const table: HtmlTag = HtmlTag::constant("table"); + pub const tbody: HtmlTag = HtmlTag::constant("tbody"); + pub const td: HtmlTag = HtmlTag::constant("td"); + pub const template: HtmlTag = HtmlTag::constant("template"); + pub const textarea: HtmlTag = HtmlTag::constant("textarea"); + pub const tfoot: HtmlTag = HtmlTag::constant("tfoot"); + pub const th: HtmlTag = HtmlTag::constant("th"); + pub const thead: HtmlTag = HtmlTag::constant("thead"); + pub const time: HtmlTag = HtmlTag::constant("time"); + pub const title: HtmlTag = HtmlTag::constant("title"); + pub const tr: HtmlTag = HtmlTag::constant("tr"); + pub const track: HtmlTag = HtmlTag::constant("track"); + pub const u: HtmlTag = HtmlTag::constant("u"); + pub const ul: HtmlTag = HtmlTag::constant("ul"); + pub const var: HtmlTag = HtmlTag::constant("var"); + pub const video: HtmlTag = HtmlTag::constant("video"); + pub const wbr: HtmlTag = HtmlTag::constant("wbr"); - tags! { - a - abbr - address - area - article - aside - audio - b - base - bdi - bdo - blockquote - body - br - button - canvas - caption - cite - code - col - colgroup - data - datalist - dd - del - details - dfn - dialog - div - dl - dt - em - embed - fieldset - figcaption - figure - footer - form - h1 - h2 - h3 - h4 - h5 - h6 - head - header - hgroup - hr - html - i - iframe - img - input - ins - kbd - label - legend - li - link - main - map - mark - menu - meta - meter - nav - noscript - object - ol - optgroup - option - output - p - param - picture - pre - progress - q - rp - rt - ruby - s - samp - script - search - section - select - slot - small - source - span - strong - style - sub - summary - sup - table - tbody - td - template - textarea - tfoot - th - thead - time - title - tr - track - u - ul - var - video - wbr - } - - /// Whether this is a void tag whose associated element may not have a + /// Whether this is a void tag whose associated element may not have /// children. pub fn is_void(tag: HtmlTag) -> bool { matches!( @@ -490,7 +479,6 @@ pub mod tag { | self::input | self::link | self::meta - | self::param | self::source | self::track | self::wbr @@ -629,36 +617,196 @@ pub mod tag { } } -/// Predefined constants for HTML attributes. -/// -/// Note: These are very incomplete. #[allow(non_upper_case_globals)] +#[rustfmt::skip] pub mod attr { - use super::HtmlAttr; - - macro_rules! attrs { - ($($attr:ident)*) => { - $(#[allow(non_upper_case_globals)] - pub const $attr: HtmlAttr = HtmlAttr::constant( - stringify!($attr) - );)* - } - } - - attrs! { - charset - cite - colspan - content - href - name - reversed - role - rowspan - start - style - value - } - + use crate::html::HtmlAttr; + pub const abbr: HtmlAttr = HtmlAttr::constant("abbr"); + pub const accept: HtmlAttr = HtmlAttr::constant("accept"); + pub const accept_charset: HtmlAttr = HtmlAttr::constant("accept-charset"); + pub const accesskey: HtmlAttr = HtmlAttr::constant("accesskey"); + pub const action: HtmlAttr = HtmlAttr::constant("action"); + pub const allow: HtmlAttr = HtmlAttr::constant("allow"); + pub const allowfullscreen: HtmlAttr = HtmlAttr::constant("allowfullscreen"); + pub const alpha: HtmlAttr = HtmlAttr::constant("alpha"); + pub const alt: HtmlAttr = HtmlAttr::constant("alt"); + pub const aria_activedescendant: HtmlAttr = HtmlAttr::constant("aria-activedescendant"); + pub const aria_atomic: HtmlAttr = HtmlAttr::constant("aria-atomic"); + pub const aria_autocomplete: HtmlAttr = HtmlAttr::constant("aria-autocomplete"); + pub const aria_busy: HtmlAttr = HtmlAttr::constant("aria-busy"); + pub const aria_checked: HtmlAttr = HtmlAttr::constant("aria-checked"); + pub const aria_colcount: HtmlAttr = HtmlAttr::constant("aria-colcount"); + pub const aria_colindex: HtmlAttr = HtmlAttr::constant("aria-colindex"); + pub const aria_colspan: HtmlAttr = HtmlAttr::constant("aria-colspan"); + pub const aria_controls: HtmlAttr = HtmlAttr::constant("aria-controls"); + pub const aria_current: HtmlAttr = HtmlAttr::constant("aria-current"); + pub const aria_describedby: HtmlAttr = HtmlAttr::constant("aria-describedby"); + pub const aria_details: HtmlAttr = HtmlAttr::constant("aria-details"); + pub const aria_disabled: HtmlAttr = HtmlAttr::constant("aria-disabled"); + pub const aria_errormessage: HtmlAttr = HtmlAttr::constant("aria-errormessage"); + pub const aria_expanded: HtmlAttr = HtmlAttr::constant("aria-expanded"); + pub const aria_flowto: HtmlAttr = HtmlAttr::constant("aria-flowto"); + pub const aria_haspopup: HtmlAttr = HtmlAttr::constant("aria-haspopup"); + pub const aria_hidden: HtmlAttr = HtmlAttr::constant("aria-hidden"); + pub const aria_invalid: HtmlAttr = HtmlAttr::constant("aria-invalid"); + pub const aria_keyshortcuts: HtmlAttr = HtmlAttr::constant("aria-keyshortcuts"); + pub const aria_label: HtmlAttr = HtmlAttr::constant("aria-label"); + pub const aria_labelledby: HtmlAttr = HtmlAttr::constant("aria-labelledby"); pub const aria_level: HtmlAttr = HtmlAttr::constant("aria-level"); + pub const aria_live: HtmlAttr = HtmlAttr::constant("aria-live"); + pub const aria_modal: HtmlAttr = HtmlAttr::constant("aria-modal"); + pub const aria_multiline: HtmlAttr = HtmlAttr::constant("aria-multiline"); + pub const aria_multiselectable: HtmlAttr = HtmlAttr::constant("aria-multiselectable"); + pub const aria_orientation: HtmlAttr = HtmlAttr::constant("aria-orientation"); + pub const aria_owns: HtmlAttr = HtmlAttr::constant("aria-owns"); + pub const aria_placeholder: HtmlAttr = HtmlAttr::constant("aria-placeholder"); + pub const aria_posinset: HtmlAttr = HtmlAttr::constant("aria-posinset"); + pub const aria_pressed: HtmlAttr = HtmlAttr::constant("aria-pressed"); + pub const aria_readonly: HtmlAttr = HtmlAttr::constant("aria-readonly"); + pub const aria_relevant: HtmlAttr = HtmlAttr::constant("aria-relevant"); + pub const aria_required: HtmlAttr = HtmlAttr::constant("aria-required"); + pub const aria_roledescription: HtmlAttr = HtmlAttr::constant("aria-roledescription"); + pub const aria_rowcount: HtmlAttr = HtmlAttr::constant("aria-rowcount"); + pub const aria_rowindex: HtmlAttr = HtmlAttr::constant("aria-rowindex"); + pub const aria_rowspan: HtmlAttr = HtmlAttr::constant("aria-rowspan"); + pub const aria_selected: HtmlAttr = HtmlAttr::constant("aria-selected"); + pub const aria_setsize: HtmlAttr = HtmlAttr::constant("aria-setsize"); + pub const aria_sort: HtmlAttr = HtmlAttr::constant("aria-sort"); + pub const aria_valuemax: HtmlAttr = HtmlAttr::constant("aria-valuemax"); + pub const aria_valuemin: HtmlAttr = HtmlAttr::constant("aria-valuemin"); + pub const aria_valuenow: HtmlAttr = HtmlAttr::constant("aria-valuenow"); + pub const aria_valuetext: HtmlAttr = HtmlAttr::constant("aria-valuetext"); + pub const r#as: HtmlAttr = HtmlAttr::constant("as"); + pub const r#async: HtmlAttr = HtmlAttr::constant("async"); + pub const autocapitalize: HtmlAttr = HtmlAttr::constant("autocapitalize"); + pub const autocomplete: HtmlAttr = HtmlAttr::constant("autocomplete"); + pub const autocorrect: HtmlAttr = HtmlAttr::constant("autocorrect"); + pub const autofocus: HtmlAttr = HtmlAttr::constant("autofocus"); + pub const autoplay: HtmlAttr = HtmlAttr::constant("autoplay"); + pub const blocking: HtmlAttr = HtmlAttr::constant("blocking"); + pub const charset: HtmlAttr = HtmlAttr::constant("charset"); + pub const checked: HtmlAttr = HtmlAttr::constant("checked"); + pub const cite: HtmlAttr = HtmlAttr::constant("cite"); + pub const class: HtmlAttr = HtmlAttr::constant("class"); + pub const closedby: HtmlAttr = HtmlAttr::constant("closedby"); + pub const color: HtmlAttr = HtmlAttr::constant("color"); + pub const colorspace: HtmlAttr = HtmlAttr::constant("colorspace"); + pub const cols: HtmlAttr = HtmlAttr::constant("cols"); + pub const colspan: HtmlAttr = HtmlAttr::constant("colspan"); + pub const command: HtmlAttr = HtmlAttr::constant("command"); + pub const commandfor: HtmlAttr = HtmlAttr::constant("commandfor"); + pub const content: HtmlAttr = HtmlAttr::constant("content"); + pub const contenteditable: HtmlAttr = HtmlAttr::constant("contenteditable"); + pub const controls: HtmlAttr = HtmlAttr::constant("controls"); + pub const coords: HtmlAttr = HtmlAttr::constant("coords"); + pub const crossorigin: HtmlAttr = HtmlAttr::constant("crossorigin"); + pub const data: HtmlAttr = HtmlAttr::constant("data"); + pub const datetime: HtmlAttr = HtmlAttr::constant("datetime"); + pub const decoding: HtmlAttr = HtmlAttr::constant("decoding"); + pub const default: HtmlAttr = HtmlAttr::constant("default"); + pub const defer: HtmlAttr = HtmlAttr::constant("defer"); + pub const dir: HtmlAttr = HtmlAttr::constant("dir"); + pub const dirname: HtmlAttr = HtmlAttr::constant("dirname"); + pub const disabled: HtmlAttr = HtmlAttr::constant("disabled"); + pub const download: HtmlAttr = HtmlAttr::constant("download"); + pub const draggable: HtmlAttr = HtmlAttr::constant("draggable"); + pub const enctype: HtmlAttr = HtmlAttr::constant("enctype"); + pub const enterkeyhint: HtmlAttr = HtmlAttr::constant("enterkeyhint"); + pub const fetchpriority: HtmlAttr = HtmlAttr::constant("fetchpriority"); + pub const r#for: HtmlAttr = HtmlAttr::constant("for"); + pub const form: HtmlAttr = HtmlAttr::constant("form"); + pub const formaction: HtmlAttr = HtmlAttr::constant("formaction"); + pub const formenctype: HtmlAttr = HtmlAttr::constant("formenctype"); + pub const formmethod: HtmlAttr = HtmlAttr::constant("formmethod"); + pub const formnovalidate: HtmlAttr = HtmlAttr::constant("formnovalidate"); + pub const formtarget: HtmlAttr = HtmlAttr::constant("formtarget"); + pub const headers: HtmlAttr = HtmlAttr::constant("headers"); + pub const height: HtmlAttr = HtmlAttr::constant("height"); + pub const hidden: HtmlAttr = HtmlAttr::constant("hidden"); + pub const high: HtmlAttr = HtmlAttr::constant("high"); + pub const href: HtmlAttr = HtmlAttr::constant("href"); + pub const hreflang: HtmlAttr = HtmlAttr::constant("hreflang"); + pub const http_equiv: HtmlAttr = HtmlAttr::constant("http-equiv"); + pub const id: HtmlAttr = HtmlAttr::constant("id"); + pub const imagesizes: HtmlAttr = HtmlAttr::constant("imagesizes"); + pub const imagesrcset: HtmlAttr = HtmlAttr::constant("imagesrcset"); + pub const inert: HtmlAttr = HtmlAttr::constant("inert"); + pub const inputmode: HtmlAttr = HtmlAttr::constant("inputmode"); + pub const integrity: HtmlAttr = HtmlAttr::constant("integrity"); + pub const is: HtmlAttr = HtmlAttr::constant("is"); + pub const ismap: HtmlAttr = HtmlAttr::constant("ismap"); + pub const itemid: HtmlAttr = HtmlAttr::constant("itemid"); + pub const itemprop: HtmlAttr = HtmlAttr::constant("itemprop"); + pub const itemref: HtmlAttr = HtmlAttr::constant("itemref"); + pub const itemscope: HtmlAttr = HtmlAttr::constant("itemscope"); + pub const itemtype: HtmlAttr = HtmlAttr::constant("itemtype"); + pub const kind: HtmlAttr = HtmlAttr::constant("kind"); + pub const label: HtmlAttr = HtmlAttr::constant("label"); + pub const lang: HtmlAttr = HtmlAttr::constant("lang"); + pub const list: HtmlAttr = HtmlAttr::constant("list"); + pub const loading: HtmlAttr = HtmlAttr::constant("loading"); + pub const r#loop: HtmlAttr = HtmlAttr::constant("loop"); + pub const low: HtmlAttr = HtmlAttr::constant("low"); + pub const max: HtmlAttr = HtmlAttr::constant("max"); + pub const maxlength: HtmlAttr = HtmlAttr::constant("maxlength"); + pub const media: HtmlAttr = HtmlAttr::constant("media"); + pub const method: HtmlAttr = HtmlAttr::constant("method"); + pub const min: HtmlAttr = HtmlAttr::constant("min"); + pub const minlength: HtmlAttr = HtmlAttr::constant("minlength"); + pub const multiple: HtmlAttr = HtmlAttr::constant("multiple"); + pub const muted: HtmlAttr = HtmlAttr::constant("muted"); + pub const name: HtmlAttr = HtmlAttr::constant("name"); + pub const nomodule: HtmlAttr = HtmlAttr::constant("nomodule"); + pub const nonce: HtmlAttr = HtmlAttr::constant("nonce"); + pub const novalidate: HtmlAttr = HtmlAttr::constant("novalidate"); + pub const open: HtmlAttr = HtmlAttr::constant("open"); + pub const optimum: HtmlAttr = HtmlAttr::constant("optimum"); + pub const pattern: HtmlAttr = HtmlAttr::constant("pattern"); + pub const ping: HtmlAttr = HtmlAttr::constant("ping"); + pub const placeholder: HtmlAttr = HtmlAttr::constant("placeholder"); + pub const playsinline: HtmlAttr = HtmlAttr::constant("playsinline"); + pub const popover: HtmlAttr = HtmlAttr::constant("popover"); + pub const popovertarget: HtmlAttr = HtmlAttr::constant("popovertarget"); + pub const popovertargetaction: HtmlAttr = HtmlAttr::constant("popovertargetaction"); + pub const poster: HtmlAttr = HtmlAttr::constant("poster"); + pub const preload: HtmlAttr = HtmlAttr::constant("preload"); + pub const readonly: HtmlAttr = HtmlAttr::constant("readonly"); + pub const referrerpolicy: HtmlAttr = HtmlAttr::constant("referrerpolicy"); + pub const rel: HtmlAttr = HtmlAttr::constant("rel"); + pub const required: HtmlAttr = HtmlAttr::constant("required"); + pub const reversed: HtmlAttr = HtmlAttr::constant("reversed"); + pub const role: HtmlAttr = HtmlAttr::constant("role"); + pub const rows: HtmlAttr = HtmlAttr::constant("rows"); + pub const rowspan: HtmlAttr = HtmlAttr::constant("rowspan"); + pub const sandbox: HtmlAttr = HtmlAttr::constant("sandbox"); + pub const scope: HtmlAttr = HtmlAttr::constant("scope"); + pub const selected: HtmlAttr = HtmlAttr::constant("selected"); + pub const shadowrootclonable: HtmlAttr = HtmlAttr::constant("shadowrootclonable"); + pub const shadowrootcustomelementregistry: HtmlAttr = HtmlAttr::constant("shadowrootcustomelementregistry"); + pub const shadowrootdelegatesfocus: HtmlAttr = HtmlAttr::constant("shadowrootdelegatesfocus"); + pub const shadowrootmode: HtmlAttr = HtmlAttr::constant("shadowrootmode"); + pub const shadowrootserializable: HtmlAttr = HtmlAttr::constant("shadowrootserializable"); + pub const shape: HtmlAttr = HtmlAttr::constant("shape"); + pub const size: HtmlAttr = HtmlAttr::constant("size"); + pub const sizes: HtmlAttr = HtmlAttr::constant("sizes"); + pub const slot: HtmlAttr = HtmlAttr::constant("slot"); + pub const span: HtmlAttr = HtmlAttr::constant("span"); + pub const spellcheck: HtmlAttr = HtmlAttr::constant("spellcheck"); + pub const src: HtmlAttr = HtmlAttr::constant("src"); + pub const srcdoc: HtmlAttr = HtmlAttr::constant("srcdoc"); + pub const srclang: HtmlAttr = HtmlAttr::constant("srclang"); + pub const srcset: HtmlAttr = HtmlAttr::constant("srcset"); + pub const start: HtmlAttr = HtmlAttr::constant("start"); + pub const step: HtmlAttr = HtmlAttr::constant("step"); + pub const style: HtmlAttr = HtmlAttr::constant("style"); + pub const tabindex: HtmlAttr = HtmlAttr::constant("tabindex"); + pub const target: HtmlAttr = HtmlAttr::constant("target"); + pub const title: HtmlAttr = HtmlAttr::constant("title"); + pub const translate: HtmlAttr = HtmlAttr::constant("translate"); + pub const r#type: HtmlAttr = HtmlAttr::constant("type"); + pub const usemap: HtmlAttr = HtmlAttr::constant("usemap"); + pub const value: HtmlAttr = HtmlAttr::constant("value"); + pub const width: HtmlAttr = HtmlAttr::constant("width"); + pub const wrap: HtmlAttr = HtmlAttr::constant("wrap"); + pub const writingsuggestions: HtmlAttr = HtmlAttr::constant("writingsuggestions"); } diff --git a/crates/typst-library/src/html/mod.rs b/crates/typst-library/src/html/mod.rs index 1d88781c1..7fc8adecd 100644 --- a/crates/typst-library/src/html/mod.rs +++ b/crates/typst-library/src/html/mod.rs @@ -1,6 +1,7 @@ //! HTML output. mod dom; +mod typed; pub use self::dom::*; @@ -14,6 +15,7 @@ pub fn module() -> Module { html.start_category(crate::Category::Html); html.define_elem::(); html.define_elem::(); + self::typed::define(&mut html); Module::new("html", html) } diff --git a/crates/typst-library/src/html/typed.rs b/crates/typst-library/src/html/typed.rs new file mode 100644 index 000000000..1e7c1ad6f --- /dev/null +++ b/crates/typst-library/src/html/typed.rs @@ -0,0 +1,868 @@ +//! The typed HTML element API (e.g. `html.div`). +//! +//! The typed API is backed by generated data derived from the HTML +//! specification. See [generated] and `tools/codegen`. + +use std::fmt::Write; +use std::num::{NonZeroI64, NonZeroU64}; +use std::sync::LazyLock; + +use bumpalo::Bump; +use comemo::Tracked; +use ecow::{eco_format, eco_vec, EcoString}; +use typst_assets::html as data; +use typst_macros::cast; + +use crate::diag::{bail, At, Hint, HintedStrResult, SourceResult}; +use crate::engine::Engine; +use crate::foundations::{ + Args, Array, AutoValue, CastInfo, Content, Context, Datetime, Dict, Duration, + FromValue, IntoValue, NativeFuncData, NativeFuncPtr, NoneValue, ParamInfo, + PositiveF64, Reflect, Scope, Str, Type, Value, +}; +use crate::html::tag; +use crate::html::{HtmlAttr, HtmlAttrs, HtmlElem, HtmlTag}; +use crate::layout::{Axes, Axis, Dir, Length}; +use crate::visualize::Color; + +/// Hook up all typed HTML definitions. +pub(super) fn define(html: &mut Scope) { + for data in FUNCS.iter() { + html.define_func_with_data(data); + } +} + +/// Lazily created functions for all typed HTML constructors. +static FUNCS: LazyLock> = LazyLock::new(|| { + // Leaking is okay here. It's not meaningfully different from having + // memory-managed values as `FUNCS` is a static. + let bump = Box::leak(Box::new(Bump::new())); + data::ELEMS.iter().map(|info| create_func_data(info, bump)).collect() +}); + +/// Creates metadata for a native HTML element constructor function. +fn create_func_data( + element: &'static data::ElemInfo, + bump: &'static Bump, +) -> NativeFuncData { + NativeFuncData { + function: NativeFuncPtr(bump.alloc( + move |_: &mut Engine, _: Tracked, args: &mut Args| { + construct(element, args) + }, + )), + name: element.name, + title: { + let title = bump.alloc_str(element.name); + title[0..1].make_ascii_uppercase(); + title + }, + docs: element.docs, + keywords: &[], + contextual: false, + scope: LazyLock::new(&|| Scope::new()), + params: LazyLock::new(bump.alloc(move || create_param_info(element))), + returns: LazyLock::new(&|| CastInfo::Type(Type::of::())), + } +} + +/// Creates parameter signature metadata for an element. +fn create_param_info(element: &'static data::ElemInfo) -> Vec { + let mut params = vec![]; + for attr in element.attributes() { + params.push(ParamInfo { + name: attr.name, + docs: attr.docs, + input: AttrType::convert(attr.ty).input(), + default: None, + positional: false, + named: true, + variadic: false, + required: false, + settable: false, + }); + } + let tag = HtmlTag::constant(element.name); + if !tag::is_void(tag) { + params.push(ParamInfo { + name: "body", + docs: "The contents of the HTML element.", + input: CastInfo::Type(Type::of::()), + default: None, + positional: true, + named: false, + variadic: false, + required: false, + settable: false, + }); + } + params +} + +/// The native constructor function shared by all HTML elements. +fn construct(element: &'static data::ElemInfo, args: &mut Args) -> SourceResult { + let mut attrs = HtmlAttrs::default(); + let mut errors = eco_vec![]; + + args.items.retain(|item| { + let Some(name) = &item.name else { return true }; + let Some(attr) = element.get_attr(name) else { return true }; + + let span = item.value.span; + let value = std::mem::take(&mut item.value.v); + let ty = AttrType::convert(attr.ty); + match ty.cast(value).at(span) { + Ok(Some(string)) => attrs.push(HtmlAttr::constant(attr.name), string), + Ok(None) => {} + Err(diags) => errors.extend(diags), + } + + false + }); + + if !errors.is_empty() { + return Err(errors); + } + + let tag = HtmlTag::constant(element.name); + let mut elem = HtmlElem::new(tag); + if !attrs.0.is_empty() { + elem.push_attrs(attrs); + } + + if !tag::is_void(tag) { + let body = args.eat::()?; + elem.push_body(body); + } + + Ok(elem.into_value()) +} + +/// A dynamic representation of an attribute's type. +/// +/// See the documentation of [`data::Type`] for more details on variants. +enum AttrType { + Presence, + Native(NativeType), + Strings(StringsType), + Union(UnionType), + List(ListType), +} + +impl AttrType { + /// Converts the type definition into a representation suitable for casting + /// and reflection. + const fn convert(ty: data::Type) -> AttrType { + use data::Type; + match ty { + Type::Presence => Self::Presence, + Type::None => Self::of::(), + Type::NoneEmpty => Self::of::(), + Type::NoneUndefined => Self::of::(), + Type::Auto => Self::of::(), + Type::TrueFalse => Self::of::(), + Type::YesNo => Self::of::(), + Type::OnOff => Self::of::(), + Type::Int => Self::of::(), + Type::NonNegativeInt => Self::of::(), + Type::PositiveInt => Self::of::(), + Type::Float => Self::of::(), + Type::PositiveFloat => Self::of::(), + Type::Str => Self::of::(), + Type::Char => Self::of::(), + Type::Datetime => Self::of::(), + Type::Duration => Self::of::(), + Type::Color => Self::of::(), + Type::HorizontalDir => Self::of::(), + Type::IconSize => Self::of::(), + Type::ImageCandidate => Self::of::(), + Type::SourceSize => Self::of::(), + Type::Strings(start, end) => Self::Strings(StringsType { start, end }), + Type::Union(variants) => Self::Union(UnionType(variants)), + Type::List(inner, separator, shorthand) => { + Self::List(ListType { inner, separator, shorthand }) + } + } + } + + /// Produces the dynamic representation of an attribute type backed by a + /// native Rust type. + const fn of() -> Self { + Self::Native(NativeType::of::()) + } + + /// See [`Reflect::input`]. + fn input(&self) -> CastInfo { + match self { + Self::Presence => bool::input(), + Self::Native(ty) => (ty.input)(), + Self::Union(ty) => ty.input(), + Self::Strings(ty) => ty.input(), + Self::List(ty) => ty.input(), + } + } + + /// See [`Reflect::castable`]. + fn castable(&self, value: &Value) -> bool { + match self { + Self::Presence => bool::castable(value), + Self::Native(ty) => (ty.castable)(value), + Self::Union(ty) => ty.castable(value), + Self::Strings(ty) => ty.castable(value), + Self::List(ty) => ty.castable(value), + } + } + + /// Tries to cast the value into this attribute's type and serialize it into + /// an HTML attribute string. + fn cast(&self, value: Value) -> HintedStrResult> { + match self { + Self::Presence => value.cast::().map(|b| b.then(EcoString::new)), + Self::Native(ty) => (ty.cast)(value), + Self::Union(ty) => ty.cast(value), + Self::Strings(ty) => ty.cast(value), + Self::List(ty) => ty.cast(value), + } + } +} + +/// An enumeration with generated string variants. +/// +/// `start` and `end` are used to index into `data::ATTR_STRINGS`. +struct StringsType { + start: usize, + end: usize, +} + +impl StringsType { + fn input(&self) -> CastInfo { + CastInfo::Union( + self.strings() + .iter() + .map(|(val, desc)| CastInfo::Value(val.into_value(), desc)) + .collect(), + ) + } + + fn castable(&self, value: &Value) -> bool { + match value { + Value::Str(s) => self.strings().iter().any(|&(v, _)| v == s.as_str()), + _ => false, + } + } + + fn cast(&self, value: Value) -> HintedStrResult> { + if self.castable(&value) { + value.cast().map(Some) + } else { + Err(self.input().error(&value)) + } + } + + fn strings(&self) -> &'static [(&'static str, &'static str)] { + &data::ATTR_STRINGS[self.start..self.end] + } +} + +/// A type that accepts any of the contained types. +struct UnionType(&'static [data::Type]); + +impl UnionType { + fn input(&self) -> CastInfo { + CastInfo::Union(self.iter().map(|ty| ty.input()).collect()) + } + + fn castable(&self, value: &Value) -> bool { + self.iter().any(|ty| ty.castable(value)) + } + + fn cast(&self, value: Value) -> HintedStrResult> { + for item in self.iter() { + if item.castable(&value) { + return item.cast(value); + } + } + Err(self.input().error(&value)) + } + + fn iter(&self) -> impl Iterator { + self.0.iter().map(|&ty| AttrType::convert(ty)) + } +} + +/// A list of items separated by a specific separator char. +/// +/// - +/// - +struct ListType { + inner: &'static data::Type, + separator: char, + shorthand: bool, +} + +impl ListType { + fn input(&self) -> CastInfo { + if self.shorthand { + Array::input() + self.inner().input() + } else { + Array::input() + } + } + + fn castable(&self, value: &Value) -> bool { + Array::castable(value) || (self.shorthand && self.inner().castable(value)) + } + + fn cast(&self, value: Value) -> HintedStrResult> { + let ty = self.inner(); + if Array::castable(&value) { + let array = value.cast::()?; + let mut out = EcoString::new(); + for (i, item) in array.into_iter().enumerate() { + let item = ty.cast(item)?.unwrap(); + if item.as_str().contains(self.separator) { + let buf; + let name = match self.separator { + ' ' => "space", + ',' => "comma", + _ => { + buf = eco_format!("'{}'", self.separator); + buf.as_str() + } + }; + bail!( + "array item may not contain a {name}"; + hint: "the array attribute will be encoded as a \ + {name}-separated string" + ); + } + if i > 0 { + out.push(self.separator); + if self.separator == ',' { + out.push(' '); + } + } + out.push_str(&item); + } + Ok(Some(out)) + } else if self.shorthand && ty.castable(&value) { + let item = ty.cast(value)?.unwrap(); + Ok(Some(item)) + } else { + Err(self.input().error(&value)) + } + } + + fn inner(&self) -> AttrType { + AttrType::convert(*self.inner) + } +} + +/// A dynamic representation of attribute backed by a native type implementing +/// - the standard `Reflect` and `FromValue` traits for casting from a value, +/// - the special `IntoAttr` trait for conversion into an attribute string. +#[derive(Copy, Clone)] +struct NativeType { + input: fn() -> CastInfo, + cast: fn(Value) -> HintedStrResult>, + castable: fn(&Value) -> bool, +} + +impl NativeType { + /// Creates a dynamic native type from a native Rust type. + const fn of() -> Self { + Self { + cast: |value| { + let this = value.cast::()?; + Ok(Some(this.into_attr())) + }, + input: T::input, + castable: T::castable, + } + } +} + +/// Casts a native type into an HTML attribute. +pub trait IntoAttr: FromValue { + /// Turn the value into an attribute string. + fn into_attr(self) -> EcoString; +} + +impl IntoAttr for Str { + fn into_attr(self) -> EcoString { + self.into() + } +} + +/// A boolean that is encoded as a string: +/// - `false` is encoded as `"false"` +/// - `true` is encoded as `"true"` +pub struct TrueFalseBool(pub bool); + +cast! { + TrueFalseBool, + v: bool => Self(v), +} + +impl IntoAttr for TrueFalseBool { + fn into_attr(self) -> EcoString { + if self.0 { "true" } else { "false" }.into() + } +} + +/// A boolean that is encoded as a string: +/// - `false` is encoded as `"no"` +/// - `true` is encoded as `"yes"` +pub struct YesNoBool(pub bool); + +cast! { + YesNoBool, + v: bool => Self(v), +} + +impl IntoAttr for YesNoBool { + fn into_attr(self) -> EcoString { + if self.0 { "yes" } else { "no" }.into() + } +} + +/// A boolean that is encoded as a string: +/// - `false` is encoded as `"off"` +/// - `true` is encoded as `"on"` +pub struct OnOffBool(pub bool); + +cast! { + OnOffBool, + v: bool => Self(v), +} + +impl IntoAttr for OnOffBool { + fn into_attr(self) -> EcoString { + if self.0 { "on" } else { "off" }.into() + } +} + +impl IntoAttr for AutoValue { + fn into_attr(self) -> EcoString { + "auto".into() + } +} + +impl IntoAttr for NoneValue { + fn into_attr(self) -> EcoString { + "none".into() + } +} + +/// A `none` value that turns into an empty string attribute. +struct NoneEmpty; + +cast! { + NoneEmpty, + _: NoneValue => NoneEmpty, +} + +impl IntoAttr for NoneEmpty { + fn into_attr(self) -> EcoString { + "".into() + } +} + +/// A `none` value that turns into the string `"undefined"`. +struct NoneUndefined; + +cast! { + NoneUndefined, + _: NoneValue => NoneUndefined, +} + +impl IntoAttr for NoneUndefined { + fn into_attr(self) -> EcoString { + "undefined".into() + } +} + +impl IntoAttr for char { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for i64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for u64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for NonZeroI64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for NonZeroU64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for f64 { + fn into_attr(self) -> EcoString { + // HTML float literal allows all the things that Rust's float `Display` + // impl produces. + eco_format!("{self}") + } +} + +impl IntoAttr for PositiveF64 { + fn into_attr(self) -> EcoString { + self.get().into_attr() + } +} + +impl IntoAttr for Color { + fn into_attr(self) -> EcoString { + eco_format!("{}", css::color(self)) + } +} + +impl IntoAttr for Duration { + fn into_attr(self) -> EcoString { + // https://html.spec.whatwg.org/#valid-duration-string + let mut out = EcoString::new(); + macro_rules! part { + ($s:literal) => { + if !out.is_empty() { + out.push(' '); + } + write!(out, $s).unwrap(); + }; + } + + let [weeks, days, hours, minutes, seconds] = self.decompose(); + if weeks > 0 { + part!("{weeks}w"); + } + if days > 0 { + part!("{days}d"); + } + if hours > 0 { + part!("{hours}h"); + } + if minutes > 0 { + part!("{minutes}m"); + } + if seconds > 0 || out.is_empty() { + part!("{seconds}s"); + } + + out + } +} + +impl IntoAttr for Datetime { + fn into_attr(self) -> EcoString { + let fmt = typst_utils::display(|f| match self { + Self::Date(date) => datetime::date(f, date), + Self::Time(time) => datetime::time(f, time), + Self::Datetime(datetime) => datetime::datetime(f, datetime), + }); + eco_format!("{fmt}") + } +} + +mod datetime { + use std::fmt::{self, Formatter, Write}; + + pub fn datetime(f: &mut Formatter, datetime: time::PrimitiveDateTime) -> fmt::Result { + // https://html.spec.whatwg.org/#valid-global-date-and-time-string + date(f, datetime.date())?; + f.write_char('T')?; + time(f, datetime.time()) + } + + pub fn date(f: &mut Formatter, date: time::Date) -> fmt::Result { + // https://html.spec.whatwg.org/#valid-date-string + write!(f, "{:04}-{:02}-{:02}", date.year(), date.month() as u8, date.day()) + } + + pub fn time(f: &mut Formatter, time: time::Time) -> fmt::Result { + // https://html.spec.whatwg.org/#valid-time-string + write!(f, "{:02}:{:02}", time.hour(), time.minute())?; + if time.second() > 0 { + write!(f, ":{:02}", time.second())?; + } + Ok(()) + } +} + +/// A direction on the X axis: `ltr` or `rtl`. +pub struct HorizontalDir(Dir); + +cast! { + HorizontalDir, + v: Dir => { + if v.axis() == Axis::Y { + bail!("direction must be horizontal"); + } + Self(v) + }, +} + +impl IntoAttr for HorizontalDir { + fn into_attr(self) -> EcoString { + self.0.into_attr() + } +} + +impl IntoAttr for Dir { + fn into_attr(self) -> EcoString { + match self { + Self::LTR => "ltr".into(), + Self::RTL => "rtl".into(), + Self::TTB => "ttb".into(), + Self::BTT => "btt".into(), + } + } +} + +/// A width/height pair for ``. +pub struct IconSize(Axes); + +cast! { + IconSize, + v: Axes => Self(v), +} + +impl IntoAttr for IconSize { + fn into_attr(self) -> EcoString { + eco_format!("{}x{}", self.0.x, self.0.y) + } +} + +/// +pub struct ImageCandidate(EcoString); + +cast! { + ImageCandidate, + mut v: Dict => { + let src = v.take("src")?.cast::()?; + let width: Option = + v.take("width").ok().map(Value::cast).transpose()?; + let density: Option = + v.take("density").ok().map(Value::cast).transpose()?; + v.finish(&["src", "width", "density"])?; + + if src.is_empty() { + bail!("`src` must not be empty"); + } else if src.starts_with(',') || src.ends_with(',') { + bail!("`src` must not start or end with a comma"); + } + + let mut out = src; + match (width, density) { + (None, None) => {} + (Some(width), None) => write!(out, " {width}w").unwrap(), + (None, Some(density)) => write!(out, " {}d", density.get()).unwrap(), + (Some(_), Some(_)) => bail!("cannot specify both `width` and `density`"), + } + + Self(out) + }, +} + +impl IntoAttr for ImageCandidate { + fn into_attr(self) -> EcoString { + self.0 + } +} + +/// +pub struct SourceSize(EcoString); + +cast! { + SourceSize, + mut v: Dict => { + let condition = v.take("condition")?.cast::()?; + let size = v + .take("size")? + .cast::() + .hint("CSS lengths that are not expressible as Typst lengths are not yet supported") + .hint("you can use `html.elem` to create a raw attribute")?; + Self(eco_format!("({condition}) {}", css::length(size))) + }, +} + +impl IntoAttr for SourceSize { + fn into_attr(self) -> EcoString { + self.0 + } +} + +/// Conversion from Typst data types into CSS data types. +/// +/// This can be moved elsewhere once we start supporting more CSS stuff. +mod css { + use std::fmt::{self, Display}; + + use typst_utils::Numeric; + + use crate::layout::Length; + use crate::visualize::{Color, Hsl, LinearRgb, Oklab, Oklch, Rgb}; + + pub fn length(length: Length) -> impl Display { + typst_utils::display(move |f| match (length.abs.is_zero(), length.em.is_zero()) { + (false, false) => { + write!(f, "calc({}pt + {}em)", length.abs.to_pt(), length.em.get()) + } + (true, false) => write!(f, "{}em", length.em.get()), + (_, true) => write!(f, "{}pt", length.abs.to_pt()), + }) + } + + pub fn color(color: Color) -> impl Display { + typst_utils::display(move |f| match color { + Color::Rgb(_) | Color::Cmyk(_) | Color::Luma(_) => rgb(f, color.to_rgb()), + Color::Oklab(v) => oklab(f, v), + Color::Oklch(v) => oklch(f, v), + Color::LinearRgb(v) => linear_rgb(f, v), + Color::Hsl(_) | Color::Hsv(_) => hsl(f, color.to_hsl()), + }) + } + + fn oklab(f: &mut fmt::Formatter<'_>, v: Oklab) -> fmt::Result { + write!( + f, + "oklab({} {} {}{})", + percent(v.l), + number(v.a), + number(v.b), + alpha(v.alpha) + ) + } + + fn oklch(f: &mut fmt::Formatter<'_>, v: Oklch) -> fmt::Result { + write!( + f, + "oklch({} {} {}deg{})", + percent(v.l), + number(v.chroma), + number(v.hue.into_degrees()), + alpha(v.alpha) + ) + } + + fn rgb(f: &mut fmt::Formatter<'_>, v: Rgb) -> fmt::Result { + if let Some(v) = rgb_to_8_bit_lossless(v) { + let (r, g, b, a) = v.into_components(); + write!(f, "#{r:02x}{g:02x}{b:02x}")?; + if a != u8::MAX { + write!(f, "{a:02x}")?; + } + Ok(()) + } else { + write!( + f, + "rgb({} {} {}{})", + percent(v.red), + percent(v.green), + percent(v.blue), + alpha(v.alpha) + ) + } + } + + /// Converts an f32 RGBA color to its 8-bit representation if the result is + /// [very close](is_very_close) to the original. + fn rgb_to_8_bit_lossless( + v: Rgb, + ) -> Option> { + let l = v.into_format::(); + let h = l.into_format::(); + (is_very_close(v.red, h.red) + && is_very_close(v.blue, h.blue) + && is_very_close(v.green, h.green) + && is_very_close(v.alpha, h.alpha)) + .then_some(l) + } + + fn linear_rgb(f: &mut fmt::Formatter<'_>, v: LinearRgb) -> fmt::Result { + write!( + f, + "color(srgb-linear {} {} {}{})", + percent(v.red), + percent(v.green), + percent(v.blue), + alpha(v.alpha), + ) + } + + fn hsl(f: &mut fmt::Formatter<'_>, v: Hsl) -> fmt::Result { + write!( + f, + "hsl({}deg {} {}{})", + number(v.hue.into_degrees()), + percent(v.saturation), + percent(v.lightness), + alpha(v.alpha), + ) + } + + /// Displays an alpha component if it not 1. + fn alpha(value: f32) -> impl Display { + typst_utils::display(move |f| { + if !is_very_close(value, 1.0) { + write!(f, " / {}", percent(value))?; + } + Ok(()) + }) + } + + /// Displays a rounded percentage. + /// + /// For a percentage, two significant digits after the comma gives us a + /// precision of 1/10_000, which is more than 12 bits (see `is_very_close`). + fn percent(ratio: f32) -> impl Display { + typst_utils::display(move |f| { + write!(f, "{}%", typst_utils::round_with_precision(ratio as f64 * 100.0, 2)) + }) + } + + /// Rounds a number for display. + /// + /// For a number between 0 and 1, four significant digits give us a + /// precision of 1/10_000, which is more than 12 bits (see `is_very_close`). + fn number(value: f32) -> impl Display { + typst_utils::round_with_precision(value as f64, 4) + } + + /// Whether two component values are close enough that there is no + /// difference when encoding them with 12-bit. 12 bit is the highest + /// reasonable color bit depth found in the industry. + fn is_very_close(a: f32, b: f32) -> bool { + const MAX_BIT_DEPTH: u32 = 12; + const EPS: f32 = 0.5 / 2_i32.pow(MAX_BIT_DEPTH) as f32; + (a - b).abs() < EPS + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tags_and_attr_const_internible() { + for elem in data::ELEMS { + let _ = HtmlTag::constant(elem.name); + } + for attr in data::ATTRS { + let _ = HtmlAttr::constant(attr.name); + } + } +} diff --git a/crates/typst-utils/src/pico.rs b/crates/typst-utils/src/pico.rs index 3aa4570f2..9f4a2fde7 100644 --- a/crates/typst-utils/src/pico.rs +++ b/crates/typst-utils/src/pico.rs @@ -204,18 +204,70 @@ mod exceptions { use std::cmp::Ordering; /// A global list of non-bitcode-encodable compile-time internible strings. + /// + /// Must be sorted. pub const LIST: &[&str] = &[ + "accept-charset", + "allowfullscreen", + "aria-activedescendant", + "aria-autocomplete", + "aria-colcount", + "aria-colindex", + "aria-controls", + "aria-describedby", + "aria-disabled", + "aria-dropeffect", + "aria-errormessage", + "aria-expanded", + "aria-haspopup", + "aria-keyshortcuts", + "aria-labelledby", + "aria-multiline", + "aria-multiselectable", + "aria-orientation", + "aria-placeholder", + "aria-posinset", + "aria-readonly", + "aria-relevant", + "aria-required", + "aria-roledescription", + "aria-rowcount", + "aria-rowindex", + "aria-selected", + "aria-valuemax", + "aria-valuemin", + "aria-valuenow", + "aria-valuetext", + "autocapitalize", "cjk-latin-spacing", + "contenteditable", "discretionary-ligatures", + "fetchpriority", + "formnovalidate", "h5", "h6", "historical-ligatures", "number-clearance", "number-margin", "numbering-scope", + "onbeforeprint", + "onbeforeunload", + "onlanguagechange", + "onmessageerror", + "onrejectionhandled", + "onunhandledrejection", "page-numbering", "par-line-marker", + "popovertarget", + "popovertargetaction", + "referrerpolicy", + "shadowrootclonable", + "shadowrootcustomelementregistry", + "shadowrootdelegatesfocus", + "shadowrootmode", + "shadowrootserializable", "transparentize", + "writingsuggestions", ]; /// Try to find the index of an exception if it exists. diff --git a/tests/ref/html/html-typed.html b/tests/ref/html/html-typed.html new file mode 100644 index 000000000..ef62538fe --- /dev/null +++ b/tests/ref/html/html-typed.html @@ -0,0 +1,63 @@ + + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + +
+
+
+
+
+
+
+
+
RTL
+ My wonderful image +
+
+ +
+
+
+
+
+
+ + + diff --git a/tests/suite/html/typed.typ b/tests/suite/html/typed.typ new file mode 100644 index 000000000..e8fa9f6e7 --- /dev/null +++ b/tests/suite/html/typed.typ @@ -0,0 +1,187 @@ +--- html-typed html --- +// String +#html.div(id: "hi") + +// Different kinds of options. +#html.div(aria-autocomplete: none) // "none" +#html.div(aria-expanded: none) // "undefined" +#html.link(referrerpolicy: none) // present + +// Different kinds of bools. +#html.div(autofocus: false) // absent +#html.div(autofocus: true) // present +#html.div(hidden: false) // absent +#html.div(hidden: true) // present +#html.div(aria-atomic: false) // "false" +#html.div(aria-atomic: true) // "true" +#html.div(translate: false) // "no" +#html.div(translate: true) // "yes" +#html.form(autocomplete: false) // "on" +#html.form(autocomplete: true) // "off" + +// Char +#html.div(accesskey: "K") + +// Int +#html.div(aria-colcount: 2) +#html.object(width: 120, height: 10) +#html.td(rowspan: 2) + +// Float +#html.meter(low: 3.4, high: 7.9) + +// Space-separated strings. +#html.div(class: "alpha") +#html.div(class: "alpha beta") +#html.div(class: ("alpha", "beta")) + +// Comma-separated strings. +#html.div(html.input(accept: "image/jpeg")) +#html.div(html.input(accept: "image/jpeg, image/png")) +#html.div(html.input(accept: ("image/jpeg", "image/png"))) + +// Comma-separated floats. +#html.area(coords: (2.3, 4, 5.6)) + +// Colors. +#for c in ( + red, + red.lighten(10%), + luma(50%), + cmyk(10%, 20%, 30%, 40%), + oklab(27%, 20%, -3%, 50%), + color.linear-rgb(20%, 30%, 40%, 50%), + color.hsl(20deg, 10%, 20%), + color.hsv(30deg, 20%, 30%), +) { + html.link(color: c) +} + +// Durations & datetimes. +#for d in ( + duration(weeks: 3, seconds: 4), + duration(days: 1, minutes: 4), + duration(), + datetime(day: 10, month: 7, year: 2005), + datetime(day: 1, month: 2, year: 0), + datetime(hour: 6, minute: 30, second: 0), + datetime(day: 1, month: 2, year: 0, hour: 11, minute: 11, second: 0), + datetime(day: 1, month: 2, year: 0, hour: 6, minute: 0, second: 9), +) { + html.div(html.time(datetime: d)) +} + +// Direction +#html.div(dir: ltr)[RTL] + +// Image candidate and source size. +#html.img( + src: "image.png", + alt: "My wonderful image", + srcset: ( + (src: "/image-120px.png", width: 120), + (src: "/image-60px.png", width: 60), + ), + sizes: ( + (condition: "min-width: 800px", size: 400pt), + (condition: "min-width: 400px", size: 250pt), + ) +) + +// String enum. +#html.form(enctype: "text/plain") +#html.form(role: "complementary") +#html.div(hidden: "until-found") + +// Or. +#html.div(aria-checked: false) +#html.div(aria-checked: true) +#html.div(aria-checked: "mixed") + +// Input value. +#html.div(html.input(value: 5.6)) +#html.div(html.input(value: red)) +#html.div(html.input(min: 3, max: 9)) + +// Icon size. +#html.link(rel: "icon", sizes: ((32, 24), (64, 48))) + +--- html-typed-dir-str html --- +// Error: 16-21 expected direction or auto, found string +#html.div(dir: "ltr") + +--- html-typed-char-too-long html --- +// Error: 22-35 expected exactly one character +#html.div(accesskey: ("Ctrl", "K")) + +--- html-typed-int-negative html --- +// Error: 18-21 number must be at least zero +#html.img(width: -10) + +--- html-typed-int-zero html --- +// Error: 22-23 number must be positive +#html.textarea(rows: 0) + +--- html-typed-float-negative html --- +// Error: 19-23 number must be positive +#html.input(step: -3.4) + +--- html-typed-string-array-with-space html --- +// Error: 18-41 array item may not contain a space +// Hint: 18-41 the array attribute will be encoded as a space-separated string +#html.div(class: ("alpha beta", "gamma")) + +--- html-typed-float-array-invalid-shorthand html --- +// Error: 20-23 expected array, found float +#html.area(coords: 4.5) + +--- html-typed-dir-vertical html --- +// Error: 16-19 direction must be horizontal +#html.div(dir: ttb) + +--- html-typed-string-enum-invalid html --- +// Error: 21-28 expected "application/x-www-form-urlencoded", "multipart/form-data", or "text/plain" +#html.form(enctype: "utf-8") + +--- html-typed-or-invalid --- +// Error: 25-31 expected boolean or "mixed" +#html.div(aria-checked: "nope") + +--- html-typed-string-enum-or-array-invalid --- +// Error: 27-33 expected array, "additions", "additions text", "all", "removals", or "text" +// Error: 49-54 expected boolean or "mixed" +#html.link(aria-relevant: "nope", aria-checked: "yes") + +--- html-typed-srcset-both-width-and-density html --- +// Error: 19-64 cannot specify both `width` and `density` +#html.img(srcset: ((src: "img.png", width: 120, density: 0.5),)) + +--- html-typed-srcset-src-comma html --- +// Error: 19-50 `src` must not start or end with a comma +#html.img(srcset: ((src: "img.png,", width: 50),)) + +--- html-typed-sizes-string-size html --- +// Error: 18-66 expected length, found string +// Hint: 18-66 CSS lengths that are not expressible as Typst lengths are not yet supported +// Hint: 18-66 you can use `html.elem` to create a raw attribute +#html.img(sizes: ((condition: "min-width: 100px", size: "10px"),)) + +--- html-typed-input-value-invalid html --- +// Error: 20-25 expected string, float, datetime, color, or array, found boolean +#html.input(value: false) + +--- html-typed-input-bound-invalid html --- +// Error: 18-21 expected string, float, or datetime, found color +#html.input(min: red) + +--- html-typed-icon-size-invalid html --- +// Error: 32-45 expected array, found string +#html.link(rel: "icon", sizes: "10x20 20x30") + +--- html-typed-hidden-none html --- +// Error: 19-23 expected boolean or "until-found", found none +#html.div(hidden: none) + +--- html-typed-invalid-body html --- +// Error: 10-14 unexpected argument +#html.img[hi]