diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2354de582..70518860e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,3 +103,15 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo install --locked cargo-fuzz@0.12.0 - run: cd tests/fuzz && cargo fuzz build --dev + + miri: + name: Check unsafe code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + components: miri + toolchain: nightly-2024-10-29 + - uses: Swatinem/rust-cache@v2 + - run: cargo miri test -p typst-library test_miri diff --git a/Cargo.lock b/Cargo.lock index 3ea423f5f..1893f89fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "codex" version = "0.1.1" -source = "git+https://github.com/typst/codex?rev=56eb217#56eb2172fc0670f4c1c8b79a63d11f9354e5babe" +source = "git+https://github.com/typst/codex?rev=9ac86f9#9ac86f96af5b89fce555e6bba8b6d1ac7b44ef00" [[package]] name = "color-print" @@ -2861,7 +2861,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-assets?rev=c1089b4#c1089b46c461bdde579c55caa941a3cc7dec3e8a" +source = "git+https://github.com/typst/typst-assets?rev=edf0d64#edf0d648376e29738a05a933af9ea99bb81557b1" [[package]] name = "typst-cli" @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-dev-assets?rev=fddbf8b#fddbf8b99506bc370ac0edcd4959add603a7fc92" +source = "git+https://github.com/typst/typst-dev-assets?rev=bfa947f#bfa947f3433d7d13a995168c40ae788a2ebfe648" [[package]] name = "typst-docs" @@ -2971,8 +2971,12 @@ dependencies = [ name = "typst-html" version = "0.13.1" dependencies = [ + "bumpalo", "comemo", "ecow", + "palette", + "time", + "typst-assets", "typst-library", "typst-macros", "typst-svg", @@ -3028,6 +3032,7 @@ version = "0.13.1" dependencies = [ "az", "bumpalo", + "codex", "comemo", "ecow", "hypher", diff --git a/Cargo.toml b/Cargo.toml index 3cfb72008..9657f207f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,8 +32,8 @@ 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 = "c1089b4" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "edf0d64" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "bfa947f" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" @@ -47,7 +47,7 @@ clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } clap_complete = "4.2.1" clap_mangen = "0.2.10" codespan-reporting = "0.11" -codex = { git = "https://github.com/typst/codex", rev = "56eb217" } +codex = { git = "https://github.com/typst/codex", rev = "9ac86f9" } color-print = "0.3.6" comemo = "0.4" csv = "1" diff --git a/README.md b/README.md index 9526f3df4..9dee102b6 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,26 @@ instant preview. To achieve these goals, we follow three core design principles: Luckily we have [`comemo`], a system for incremental compilation which does most of the hard work in the background. +## Acknowledgements + +We'd like to thank everyone who is supporting Typst's development, be it via +[GitHub sponsors] or elsewhere. In particular, special thanks[^1] go to: + +- [Posit](https://posit.co/blog/posit-and-typst/) for financing a full-time + compiler engineer +- [NLnet](https://nlnet.nl/) for supporting work on Typst via multiple grants + through the [NGI Zero Core](https://nlnet.nl/core) fund: + - Work on [HTML export](https://nlnet.nl/project/Typst-HTML/) + - Work on [PDF accessibility](https://nlnet.nl/project/Typst-Accessibility/) +- [Science & Startups](https://www.science-startups.berlin/) for having financed + Typst development from January through June 2023 via the Berlin Startup + Scholarship +- [Zerodha](https://zerodha.tech/blog/1-5-million-pdfs-in-25-minutes/) for their + generous one-time sponsorship + +[^1]: This list only includes contributions for our open-source work that exceed + or are expected to exceed €10K. + [docs]: https://typst.app/docs/ [app]: https://typst.app/ [discord]: https://discord.gg/2uDybryKPe @@ -259,3 +279,4 @@ instant preview. To achieve these goals, we follow three core design principles: [packages]: https://github.com/typst/packages/ [`comemo`]: https://github.com/typst/comemo/ [snap]: https://snapcraft.io/typst +[GitHub sponsors]: https://github.com/sponsors/typst/ diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 7e9b93f93..792cabae1 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -29,6 +29,7 @@ typst-svg = { workspace = true } typst-timing = { workspace = true } chrono = { workspace = true } clap = { workspace = true } +clap_complete = { workspace = true } codespan-reporting = { workspace = true } color-print = { workspace = true } comemo = { workspace = true } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 85ef3adc3..f31d26a2e 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -7,6 +7,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use clap::builder::{TypedValueParser, ValueParser}; use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand, ValueEnum, ValueHint}; +use clap_complete::Shell; use semver::Version; /// The character typically used to separate path components @@ -81,6 +82,9 @@ pub enum Command { /// Self update the Typst CLI. #[cfg_attr(not(feature = "self-update"), clap(hide = true))] Update(UpdateCommand), + + /// Generates shell completion scripts. + Completions(CompletionsCommand), } /// Compiles an input file into a supported output format. @@ -202,6 +206,14 @@ pub struct UpdateCommand { pub backup_path: Option, } +/// Generates shell completion scripts. +#[derive(Debug, Clone, Parser)] +pub struct CompletionsCommand { + /// The shell to generate completions for. + #[arg(value_enum)] + pub shell: Shell, +} + /// Arguments for compilation and watching. #[derive(Debug, Clone, Args)] pub struct CompileArgs { @@ -507,7 +519,7 @@ pub enum PdfStandard { /// PDF/A-2u. #[value(name = "a-2u")] A_2u, - /// PDF/A-3u. + /// PDF/A-3b. #[value(name = "a-3b")] A_3b, /// PDF/A-3u. diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 207bb7d09..0db67b454 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -14,10 +14,10 @@ use typst::diag::{ bail, At, Severity, SourceDiagnostic, SourceResult, StrResult, Warned, }; use typst::foundations::{Datetime, Smart}; -use typst::html::HtmlDocument; use typst::layout::{Frame, Page, PageRanges, PagedDocument}; use typst::syntax::{FileId, Lines, Span}; use typst::WorldExt; +use typst_html::HtmlDocument; use typst_pdf::{PdfOptions, PdfStandards, Timestamp}; use crate::args::{ diff --git a/crates/typst-cli/src/completions.rs b/crates/typst-cli/src/completions.rs new file mode 100644 index 000000000..51e7db103 --- /dev/null +++ b/crates/typst-cli/src/completions.rs @@ -0,0 +1,13 @@ +use std::io::stdout; + +use clap::CommandFactory; +use clap_complete::generate; + +use crate::args::{CliArguments, CompletionsCommand}; + +/// Execute the completions command. +pub fn completions(command: &CompletionsCommand) { + let mut cmd = CliArguments::command(); + let bin_name = cmd.get_name().to_string(); + generate(command.shell, &mut cmd, bin_name, &mut stdout()); +} diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 14f8a665d..6a3b337d8 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -1,5 +1,6 @@ mod args; mod compile; +mod completions; mod download; mod fonts; mod greet; @@ -71,6 +72,7 @@ fn dispatch() -> HintedStrResult<()> { Command::Query(command) => crate::query::query(command)?, Command::Fonts(command) => crate::fonts::fonts(command), Command::Update(command) => crate::update::update(command)?, + Command::Completions(command) => crate::completions::completions(command), } Ok(()) diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 95bee235c..8ad766b14 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -12,7 +12,7 @@ use typst::foundations::{Bytes, Datetime, Dict, IntoValue}; use typst::syntax::{FileId, Lines, Source, VirtualPath}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; -use typst::{Library, World}; +use typst::{Library, LibraryExt, World}; use typst_kit::fonts::{FontSlot, Fonts}; use typst_kit::package::PackageStorage; use typst_timing::timed; diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 5beefa912..cc9606269 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -186,7 +186,7 @@ impl Eval for ast::Raw<'_> { let lines = self.lines().map(|line| (line.get().clone(), line.span())).collect(); let mut elem = RawElem::new(RawContent::Lines(lines)).with_block(self.block()); if let Some(lang) = self.lang() { - elem.push_lang(Some(lang.get().clone())); + elem.lang.set(Some(lang.get().clone())); } Ok(elem.pack()) } @@ -205,7 +205,9 @@ impl Eval for ast::Label<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Label(Label::new(PicoStr::intern(self.get())))) + Ok(Value::Label( + Label::new(PicoStr::intern(self.get())).expect("unexpected empty label"), + )) } } @@ -213,12 +215,12 @@ impl Eval for ast::Ref<'_> { type Output = Content; fn eval(self, vm: &mut Vm) -> SourceResult { - let target = Label::new(PicoStr::intern(self.target())); + let target = Label::new(PicoStr::intern(self.target())) + .expect("unexpected empty reference"); let mut elem = RefElem::new(target); if let Some(supplement) = self.supplement() { - elem.push_supplement(Smart::Custom(Some(Supplement::Content( - supplement.eval(vm)?, - )))); + elem.supplement + .set(Smart::Custom(Some(Supplement::Content(supplement.eval(vm)?)))); } Ok(elem.pack()) } @@ -249,7 +251,7 @@ impl Eval for ast::EnumItem<'_> { let body = self.body().eval(vm)?; let mut elem = EnumItem::new(body); if let Some(number) = self.number() { - elem.push_number(Some(number)); + elem.number.set(Some(number)); } Ok(elem.pack()) } diff --git a/crates/typst-eval/src/math.rs b/crates/typst-eval/src/math.rs index 0e271a089..c2325a8c5 100644 --- a/crates/typst-eval/src/math.rs +++ b/crates/typst-eval/src/math.rs @@ -80,17 +80,17 @@ impl Eval for ast::MathAttach<'_> { let mut elem = AttachElem::new(base); if let Some(expr) = self.top() { - elem.push_t(Some(expr.eval_display(vm)?)); + elem.t.set(Some(expr.eval_display(vm)?)); } // Always attach primes in scripts style (not limits style), // i.e. at the top-right corner. if let Some(primes) = self.primes() { - elem.push_tr(Some(primes.eval(vm)?)); + elem.tr.set(Some(primes.eval(vm)?)); } if let Some(expr) = self.bottom() { - elem.push_b(Some(expr.eval_display(vm)?)); + elem.b.set(Some(expr.eval_display(vm)?)); } Ok(elem.pack()) diff --git a/crates/typst-eval/src/rules.rs b/crates/typst-eval/src/rules.rs index f4c1563f3..eb6a1e6da 100644 --- a/crates/typst-eval/src/rules.rs +++ b/crates/typst-eval/src/rules.rs @@ -1,6 +1,6 @@ use typst_library::diag::{warning, At, SourceResult}; use typst_library::foundations::{ - Element, Fields, Func, Recipe, Selector, ShowableSelector, Styles, Transformation, + Element, Func, Recipe, Selector, ShowableSelector, Styles, Transformation, }; use typst_library::layout::BlockElem; use typst_library::model::ParElem; @@ -62,8 +62,7 @@ fn check_show_par_set_block(vm: &mut Vm, recipe: &Recipe) { if let Some(Selector::Elem(elem, _)) = recipe.selector(); if *elem == Element::of::(); if let Transformation::Style(styles) = recipe.transform(); - if styles.has::(::Enum::Above as _) || - styles.has::(::Enum::Below as _); + if styles.has(BlockElem::above) || styles.has(BlockElem::below); then { vm.engine.sink.warn(warning!( recipe.span(), diff --git a/crates/typst-html/Cargo.toml b/crates/typst-html/Cargo.toml index 534848f96..54cad0124 100644 --- a/crates/typst-html/Cargo.toml +++ b/crates/typst-html/Cargo.toml @@ -13,14 +13,18 @@ keywords = { workspace = true } readme = { workspace = true } [dependencies] +typst-assets = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } typst-svg = { workspace = true } +bumpalo = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } +palette = { workspace = true } +time = { workspace = true } [lints] workspace = true diff --git a/crates/typst-html/src/attr.rs b/crates/typst-html/src/attr.rs new file mode 100644 index 000000000..0fec3955d --- /dev/null +++ b/crates/typst-html/src/attr.rs @@ -0,0 +1,195 @@ + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(non_upper_case_globals)] +#![allow(dead_code)] + +use crate::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-html/src/charsets.rs b/crates/typst-html/src/charsets.rs new file mode 100644 index 000000000..251ff15c9 --- /dev/null +++ b/crates/typst-html/src/charsets.rs @@ -0,0 +1,81 @@ +//! Defines syntactical properties of HTML tags, attributes, and text. + +/// Check whether a character is in a tag name. +pub const fn is_valid_in_tag_name(c: char) -> bool { + c.is_ascii_alphanumeric() +} + +/// Check whether a character is valid in an attribute name. +pub const fn is_valid_in_attribute_name(c: char) -> bool { + match c { + // These are forbidden. + '\0' | ' ' | '"' | '\'' | '>' | '/' | '=' => false, + c if is_whatwg_control_char(c) => false, + c if is_whatwg_non_char(c) => false, + // _Everything_ else is allowed, including U+2029 paragraph + // separator. Go wild. + _ => true, + } +} + +/// Check whether a character can be an used in an attribute value without +/// escaping. +/// +/// See +pub const fn is_valid_in_attribute_value(c: char) -> bool { + match c { + // Ampersands are sometimes legal (i.e. when they are not _ambiguous + // ampersands_) but it is not worth the trouble to check for that. + '&' => false, + // Quotation marks are not allowed in double-quote-delimited attribute + // values. + '"' => false, + // All other text characters are allowed. + c => is_w3c_text_char(c), + } +} + +/// Check whether a character can be an used in normal text without +/// escaping. +pub const fn is_valid_in_normal_element_text(c: char) -> bool { + match c { + // Ampersands are sometimes legal (i.e. when they are not _ambiguous + // ampersands_) but it is not worth the trouble to check for that. + '&' => false, + // Less-than signs are not allowed in text. + '<' => false, + // All other text characters are allowed. + c => is_w3c_text_char(c), + } +} + +/// Check if something is valid text in HTML. +pub const fn is_w3c_text_char(c: char) -> bool { + match c { + // Non-characters are obviously not text characters. + c if is_whatwg_non_char(c) => false, + // Control characters are disallowed, except for whitespace. + c if is_whatwg_control_char(c) => c.is_ascii_whitespace(), + // Everything else is allowed. + _ => true, + } +} + +const fn is_whatwg_non_char(c: char) -> bool { + match c { + '\u{fdd0}'..='\u{fdef}' => true, + // Non-characters matching xxFFFE or xxFFFF up to x10FFFF (inclusive). + c if c as u32 & 0xfffe == 0xfffe && c as u32 <= 0x10ffff => true, + _ => false, + } +} + +const fn is_whatwg_control_char(c: char) -> bool { + match c { + // C0 control characters. + '\u{00}'..='\u{1f}' => true, + // Other control characters. + '\u{7f}'..='\u{9f}' => true, + _ => false, + } +} diff --git a/crates/typst-html/src/convert.rs b/crates/typst-html/src/convert.rs new file mode 100644 index 000000000..817b0f025 --- /dev/null +++ b/crates/typst-html/src/convert.rs @@ -0,0 +1,127 @@ +use typst_library::diag::{warning, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, StyleChain, Target, TargetElem}; +use typst_library::introspection::{SplitLocator, TagElem}; +use typst_library::layout::{Abs, Axes, BlockBody, BlockElem, BoxElem, Region, Size}; +use typst_library::model::ParElem; +use typst_library::routines::Pair; +use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; + +use crate::fragment::html_fragment; +use crate::{attr, tag, FrameElem, HtmlElem, HtmlElement, HtmlFrame, HtmlNode}; + +/// Converts realized content into HTML nodes. +pub fn convert_to_nodes<'a>( + engine: &mut Engine, + locator: &mut SplitLocator, + children: impl IntoIterator>, +) -> SourceResult> { + let mut output = Vec::new(); + for (child, styles) in children { + handle(engine, child, locator, styles, &mut output)?; + } + Ok(output) +} + +/// Convert one element into HTML node(s). +fn handle( + engine: &mut Engine, + child: &Content, + locator: &mut SplitLocator, + styles: StyleChain, + output: &mut Vec, +) -> SourceResult<()> { + if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::Tag(elem.tag.clone())); + } else if let Some(elem) = child.to_packed::() { + let mut children = vec![]; + if let Some(body) = elem.body.get_ref(styles) { + children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; + } + let element = HtmlElement { + tag: elem.tag, + attrs: elem.attrs.get_cloned(styles), + children, + span: elem.span(), + }; + output.push(element.into()); + } else if let Some(elem) = child.to_packed::() { + let children = + html_fragment(engine, &elem.body, locator.next(&elem.span()), styles)?; + output.push( + HtmlElement::new(tag::p) + .with_children(children) + .spanned(elem.span()) + .into(), + ); + } else if let Some(elem) = child.to_packed::() { + // TODO: This is rather incomplete. + if let Some(body) = elem.body.get_ref(styles) { + let children = + html_fragment(engine, body, locator.next(&elem.span()), styles)?; + output.push( + HtmlElement::new(tag::span) + .with_attr(attr::style, "display: inline-block;") + .with_children(children) + .spanned(elem.span()) + .into(), + ) + } + } else if let Some((elem, body)) = + child + .to_packed::() + .and_then(|elem| match elem.body.get_ref(styles) { + Some(BlockBody::Content(body)) => Some((elem, body)), + _ => None, + }) + { + // TODO: This is rather incomplete. + let children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; + output.push( + HtmlElement::new(tag::div) + .with_children(children) + .spanned(elem.span()) + .into(), + ); + } else if child.is::() { + output.push(HtmlNode::text(' ', child.span())); + } else if let Some(elem) = child.to_packed::() { + let text = if let Some(case) = styles.get(TextElem::case) { + case.apply(&elem.text).into() + } else { + elem.text.clone() + }; + output.push(HtmlNode::text(text, elem.span())); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlElement::new(tag::br).spanned(elem.span()).into()); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::text( + if elem.double.get(styles) { '"' } else { '\'' }, + child.span(), + )); + } else if let Some(elem) = child.to_packed::() { + let locator = locator.next(&elem.span()); + let style = TargetElem::target.set(Target::Paged).wrap(); + let frame = (engine.routines.layout_frame)( + engine, + &elem.body, + locator, + styles.chain(&style), + Region::new(Size::splat(Abs::inf()), Axes::splat(false)), + )?; + output.push(HtmlNode::Frame(HtmlFrame::new(frame, styles))); + } else { + engine.sink.warn(warning!( + child.span(), + "{} was ignored during HTML export", + child.elem().name() + )); + } + Ok(()) +} + +/// Checks whether the given element is an inline-level HTML element. +pub fn is_inline(elem: &Content) -> bool { + elem.to_packed::() + .is_some_and(|elem| tag::is_inline_by_default(elem.tag)) +} diff --git a/crates/typst-html/src/css.rs b/crates/typst-html/src/css.rs new file mode 100644 index 000000000..5916d3147 --- /dev/null +++ b/crates/typst-html/src/css.rs @@ -0,0 +1,178 @@ +//! Conversion from Typst data types into CSS data types. + +use std::fmt::{self, Display, Write}; + +use ecow::EcoString; +use typst_library::layout::{Length, Rel}; +use typst_library::visualize::{Color, Hsl, LinearRgb, Oklab, Oklch, Rgb}; +use typst_utils::Numeric; + +/// A list of CSS properties with values. +#[derive(Debug, Default)] +pub struct Properties(EcoString); + +impl Properties { + /// Creates an empty list. + pub fn new() -> Self { + Self::default() + } + + /// Adds a new property to the list. + pub fn push(&mut self, property: &str, value: impl Display) { + if !self.0.is_empty() { + self.0.push_str("; "); + } + write!(&mut self.0, "{property}: {value}").unwrap(); + } + + /// Adds a new property in builder-style. + #[expect(unused)] + pub fn with(mut self, property: &str, value: impl Display) -> Self { + self.push(property, value); + self + } + + /// Turns this into a string suitable for use as an inline `style` + /// attribute. + pub fn into_inline_styles(self) -> Option { + (!self.0.is_empty()).then_some(self.0) + } +} + +pub fn rel(rel: Rel) -> impl Display { + typst_utils::display(move |f| match (rel.abs.is_zero(), rel.rel.is_zero()) { + (false, false) => { + write!(f, "calc({}% + {})", rel.rel.get(), length(rel.abs)) + } + (true, false) => write!(f, "{}%", rel.rel.get()), + (_, true) => write!(f, "{}", length(rel.abs)), + }) +} + +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 +} diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs new file mode 100644 index 000000000..9f0124e57 --- /dev/null +++ b/crates/typst-html/src/document.rs @@ -0,0 +1,219 @@ +use std::num::NonZeroUsize; + +use comemo::{Tracked, TrackedMut}; +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::foundations::{Content, StyleChain}; +use typst_library::introspection::{Introspector, IntrospectorBuilder, Locator}; +use typst_library::layout::{Point, Position, Transform}; +use typst_library::model::DocumentInfo; +use typst_library::routines::{Arenas, RealizationKind, Routines}; +use typst_library::World; +use typst_syntax::Span; +use typst_utils::NonZeroExt; + +use crate::{attr, tag, HtmlDocument, HtmlElement, HtmlNode}; + +/// Produce an HTML document from content. +/// +/// This first performs root-level realization and then turns the resulting +/// elements into HTML. +#[typst_macros::time(name = "html document")] +pub fn html_document( + engine: &mut Engine, + content: &Content, + styles: StyleChain, +) -> SourceResult { + html_document_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + styles, + ) +} + +/// The internal implementation of `html_document`. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_document_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + styles: StyleChain, +) -> SourceResult { + let mut locator = Locator::root().split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route).unnested(), + }; + + // Mark the external styles as "outside" so that they are valid at the page + // level. + let styles = styles.to_map().outside(); + let styles = StyleChain::new(&styles); + + let arenas = Arenas::default(); + let mut info = DocumentInfo::default(); + let children = (engine.routines.realize)( + RealizationKind::HtmlDocument { + info: &mut info, + is_inline: crate::convert::is_inline, + }, + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + let output = crate::convert::convert_to_nodes( + &mut engine, + &mut locator, + children.iter().copied(), + )?; + let introspector = introspect_html(&output); + let root = root_element(output, &info)?; + + Ok(HtmlDocument { info, root, introspector }) +} + +/// Introspects HTML nodes. +#[typst_macros::time(name = "introspect html")] +fn introspect_html(output: &[HtmlNode]) -> Introspector { + fn discover( + builder: &mut IntrospectorBuilder, + sink: &mut Vec<(Content, Position)>, + nodes: &[HtmlNode], + ) { + for node in nodes { + match node { + HtmlNode::Tag(tag) => builder.discover_in_tag( + sink, + tag, + Position { page: NonZeroUsize::ONE, point: Point::zero() }, + ), + HtmlNode::Text(_, _) => {} + HtmlNode::Element(elem) => discover(builder, sink, &elem.children), + HtmlNode::Frame(frame) => builder.discover_in_frame( + sink, + &frame.inner, + NonZeroUsize::ONE, + Transform::identity(), + ), + } + } + } + + let mut elems = Vec::new(); + let mut builder = IntrospectorBuilder::new(); + discover(&mut builder, &mut elems, output); + builder.finalize(elems) +} + +/// Wrap the nodes in `` and `` if they are not yet rooted, +/// supplying a suitable ``. +fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { + let head = head_element(info); + let body = match classify_output(output)? { + OutputKind::Html(element) => return Ok(element), + OutputKind::Body(body) => body, + OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), + }; + Ok(HtmlElement::new(tag::html).with_children(vec![head.into(), body.into()])) +} + +/// Generate a `` element. +fn head_element(info: &DocumentInfo) -> HtmlElement { + let mut children = vec![]; + + children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into()); + + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "viewport") + .with_attr(attr::content, "width=device-width, initial-scale=1") + .into(), + ); + + if let Some(title) = &info.title { + children.push( + HtmlElement::new(tag::title) + .with_children(vec![HtmlNode::Text(title.clone(), Span::detached())]) + .into(), + ); + } + + if let Some(description) = &info.description { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "description") + .with_attr(attr::content, description.clone()) + .into(), + ); + } + + if !info.author.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "authors") + .with_attr(attr::content, info.author.join(", ")) + .into(), + ) + } + + if !info.keywords.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "keywords") + .with_attr(attr::content, info.keywords.join(", ")) + .into(), + ) + } + + HtmlElement::new(tag::head).with_children(children) +} + +/// Determine which kind of output the user generated. +fn classify_output(mut output: Vec) -> SourceResult { + let count = output.iter().filter(|node| !matches!(node, HtmlNode::Tag(_))).count(); + for node in &mut output { + let HtmlNode::Element(elem) = node else { continue }; + let tag = elem.tag; + let mut take = || std::mem::replace(elem, HtmlElement::new(tag::html)); + match (tag, count) { + (tag::html, 1) => return Ok(OutputKind::Html(take())), + (tag::body, 1) => return Ok(OutputKind::Body(take())), + (tag::html | tag::body, _) => bail!( + elem.span, + "`{}` element must be the only element in the document", + elem.tag, + ), + _ => {} + } + } + Ok(OutputKind::Leafs(output)) +} + +/// What kinds of output the user generated. +enum OutputKind { + /// The user generated their own `` element. We do not need to supply + /// one. + Html(HtmlElement), + /// The user generate their own `` element. We do not need to supply + /// one, but need supply the `` element. + Body(HtmlElement), + /// The user generated leafs which we wrap in a `` and ``. + Leafs(Vec), +} diff --git a/crates/typst-html/src/dom.rs b/crates/typst-html/src/dom.rs new file mode 100644 index 000000000..d7287d42d --- /dev/null +++ b/crates/typst-html/src/dom.rs @@ -0,0 +1,289 @@ +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, 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}; + +use crate::charsets; + +/// An HTML document. +#[derive(Debug, Clone)] +pub struct HtmlDocument { + /// The document's root HTML element. + pub root: HtmlElement, + /// Details about the document. + pub info: DocumentInfo, + /// Provides the ability to execute queries on the document. + pub introspector: Introspector, +} + +/// A child of an HTML element. +#[derive(Debug, Clone, Hash)] +pub enum HtmlNode { + /// An introspectable element that produced something within this node. + Tag(Tag), + /// Plain text. + Text(EcoString, Span), + /// Another element. + Element(HtmlElement), + /// Layouted content that will be embedded into HTML as an SVG. + Frame(HtmlFrame), +} + +impl HtmlNode { + /// Create a plain text node. + pub fn text(text: impl Into, span: Span) -> Self { + Self::Text(text.into(), span) + } +} + +impl From for HtmlNode { + fn from(element: HtmlElement) -> Self { + Self::Element(element) + } +} + +/// An HTML element. +#[derive(Debug, Clone, Hash)] +pub struct HtmlElement { + /// The HTML tag. + pub tag: HtmlTag, + /// The element's attributes. + pub attrs: HtmlAttrs, + /// The element's children. + pub children: Vec, + /// The span from which the element originated, if any. + pub span: Span, +} + +impl HtmlElement { + /// Create a new, blank element without attributes or children. + pub fn new(tag: HtmlTag) -> Self { + Self { + tag, + attrs: HtmlAttrs::default(), + children: vec![], + span: Span::detached(), + } + } + + /// Attach children to the element. + /// + /// Note: This overwrites potential previous children. + pub fn with_children(mut self, children: Vec) -> Self { + self.children = children; + self + } + + /// Add an atribute to the element. + pub fn with_attr(mut self, key: HtmlAttr, value: impl Into) -> Self { + self.attrs.push(key, value); + self + } + + /// Attach a span to the element. + pub fn spanned(mut self, span: Span) -> Self { + self.span = span; + self + } +} + +/// The tag of an HTML element. +#[derive(Copy, Clone, Eq, PartialEq, Hash)] +pub struct HtmlTag(PicoStr); + +impl HtmlTag { + /// Intern an HTML tag string at runtime. + pub fn intern(string: &str) -> StrResult { + if string.is_empty() { + bail!("tag name must not be empty"); + } + + if let Some(c) = string.chars().find(|&c| !charsets::is_valid_in_tag_name(c)) { + bail!("the character {} is not valid in a tag name", c.repr()); + } + + Ok(Self(PicoStr::intern(string))) + } + + /// Creates a compile-time constant `HtmlTag`. + /// + /// Should only be used in const contexts because it can panic. + #[track_caller] + pub const fn constant(string: &'static str) -> Self { + if string.is_empty() { + panic!("tag name must not be empty"); + } + + let bytes = string.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !bytes[i].is_ascii() || !charsets::is_valid_in_tag_name(bytes[i] as char) { + panic!("not all characters are valid in a tag name"); + } + i += 1; + } + + Self(PicoStr::constant(string)) + } + + /// Resolves the tag to a string. + pub fn resolve(self) -> ResolvedPicoStr { + self.0.resolve() + } + + /// Turns the tag into its inner interned string. + pub const fn into_inner(self) -> PicoStr { + self.0 + } +} + +impl Debug for HtmlTag { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self, f) + } +} + +impl Display for HtmlTag { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "<{}>", self.resolve()) + } +} + +cast! { + HtmlTag, + self => self.0.resolve().as_str().into_value(), + v: Str => Self::intern(&v)?, +} + +/// Attributes of an HTML element. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct HtmlAttrs(pub EcoVec<(HtmlAttr, EcoString)>); + +impl HtmlAttrs { + /// Creates an empty attribute list. + pub fn new() -> Self { + Self::default() + } + + /// Add an attribute. + pub fn push(&mut self, attr: HtmlAttr, value: impl Into) { + self.0.push((attr, value.into())); + } +} + +cast! { + HtmlAttrs, + self => self.0 + .into_iter() + .map(|(key, value)| (key.resolve().as_str().into(), value.into_value())) + .collect::() + .into_value(), + values: Dict => Self(values + .into_iter() + .map(|(k, v)| { + let attr = HtmlAttr::intern(&k)?; + let value = v.cast::()?; + Ok((attr, value)) + }) + .collect::>()?), +} + +/// An attribute of an HTML element. +#[derive(Copy, Clone, Eq, PartialEq, Hash)] +pub struct HtmlAttr(PicoStr); + +impl HtmlAttr { + /// Intern an HTML attribute string at runtime. + pub fn intern(string: &str) -> StrResult { + if string.is_empty() { + bail!("attribute name must not be empty"); + } + + if let Some(c) = + string.chars().find(|&c| !charsets::is_valid_in_attribute_name(c)) + { + bail!("the character {} is not valid in an attribute name", c.repr()); + } + + Ok(Self(PicoStr::intern(string))) + } + + /// Creates a compile-time constant `HtmlAttr`. + /// + /// Must only be used in const contexts (in a constant definition or + /// explicit `const { .. }` block) because otherwise a panic for a malformed + /// attribute or not auto-internible constant will only be caught at + /// runtime. + #[track_caller] + pub const fn constant(string: &'static str) -> Self { + if string.is_empty() { + panic!("attribute name must not be empty"); + } + + let bytes = string.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !bytes[i].is_ascii() + || !charsets::is_valid_in_attribute_name(bytes[i] as char) + { + panic!("not all characters are valid in an attribute name"); + } + i += 1; + } + + Self(PicoStr::constant(string)) + } + + /// Resolves the attribute to a string. + pub fn resolve(self) -> ResolvedPicoStr { + self.0.resolve() + } + + /// Turns the attribute into its inner interned string. + pub const fn into_inner(self) -> PicoStr { + self.0 + } +} + +impl Debug for HtmlAttr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self, f) + } +} + +impl Display for HtmlAttr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.resolve()) + } +} + +cast! { + HtmlAttr, + self => self.0.resolve().as_str().into_value(), + v: Str => Self::intern(&v)?, +} + +/// Layouted content that will be embedded into HTML as an SVG. +#[derive(Debug, Clone, Hash)] +pub struct HtmlFrame { + /// The frame that will be displayed as an SVG. + pub inner: Frame, + /// The text size where the frame was defined. This is used to size the + /// frame with em units to make text in and outside of the frame sized + /// 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) } + } +} diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 9c7938360..4447186b8 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -2,12 +2,12 @@ use std::fmt::Write; use typst_library::diag::{bail, At, SourceResult, StrResult}; use typst_library::foundations::Repr; -use typst_library::html::{ - attr, charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag, -}; -use typst_library::layout::Frame; use typst_syntax::Span; +use crate::{ + attr, charsets, tag, HtmlDocument, HtmlElement, HtmlFrame, HtmlNode, HtmlTag, +}; + /// Encodes an HTML document into a string. pub fn html(document: &HtmlDocument) -> SourceResult { let mut w = Writer { pretty: true, ..Writer::default() }; @@ -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, }); @@ -304,9 +305,7 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> { } /// Encode a laid out frame into the writer. -fn write_frame(w: &mut Writer, frame: &Frame) { - // FIXME: This string replacement is obviously a hack. - let svg = typst_svg::svg_frame(frame) - .replace(" SourceResult> { + html_fragment_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + locator.track(), + styles, + ) +} + +/// The cached, internal implementation of [`html_fragment`]. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_fragment_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + locator: Tracked, + styles: StyleChain, +) -> SourceResult> { + let link = LocatorLink::new(locator); + let mut locator = Locator::link(&link).split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + engine.route.check_html_depth().at(content.span())?; + + let arenas = Arenas::default(); + let children = (engine.routines.realize)( + // No need to know about the `FragmentKind` because we handle both + // uniformly. + RealizationKind::HtmlFragment { + kind: &mut FragmentKind::Block, + is_inline: crate::convert::is_inline, + }, + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + crate::convert::convert_to_nodes(&mut engine, &mut locator, children.iter().copied()) +} diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index 703948f66..d7b29dbbc 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -1,354 +1,108 @@ //! Typst's HTML exporter. +mod attr; +mod charsets; +mod convert; +mod css; +mod document; +mod dom; mod encode; +mod fragment; +mod rules; +mod tag; +mod typed; +pub use self::document::html_document; +pub use self::dom::*; pub use self::encode::html; +pub use self::rules::register; -use comemo::{Track, Tracked, TrackedMut}; -use typst_library::diag::{bail, warning, At, SourceResult}; -use typst_library::engine::{Engine, Route, Sink, Traced}; -use typst_library::foundations::{Content, StyleChain, Target, TargetElem}; -use typst_library::html::{ - attr, tag, FrameElem, HtmlDocument, HtmlElem, HtmlElement, HtmlNode, -}; -use typst_library::introspection::{ - Introspector, Locator, LocatorLink, SplitLocator, TagElem, -}; -use typst_library::layout::{Abs, Axes, BlockBody, BlockElem, BoxElem, Region, Size}; -use typst_library::model::{DocumentInfo, ParElem}; -use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind, Routines}; -use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; -use typst_library::World; -use typst_syntax::Span; +use ecow::EcoString; +use typst_library::foundations::{Content, Module, Scope}; +use typst_library::Category; +use typst_macros::elem; -/// Produce an HTML document from content. +/// Creates the module with all HTML definitions. +pub fn module() -> Module { + let mut html = Scope::deduplicating(); + html.start_category(Category::Html); + html.define_elem::(); + html.define_elem::(); + crate::typed::define(&mut html); + Module::new("html", html) +} + +/// An HTML element that can contain Typst content. /// -/// This first performs root-level realization and then turns the resulting -/// elements into HTML. -#[typst_macros::time(name = "html document")] -pub fn html_document( - engine: &mut Engine, - content: &Content, - styles: StyleChain, -) -> SourceResult { - html_document_impl( - engine.routines, - engine.world, - engine.introspector, - engine.traced, - TrackedMut::reborrow_mut(&mut engine.sink), - engine.route.track(), - content, - styles, - ) +/// Typst's HTML export automatically generates the appropriate tags for most +/// elements. However, sometimes, it is desirable to retain more control. For +/// example, when using Typst to generate your blog, you could use this function +/// to wrap each article in an `
` tag. +/// +/// Typst is aware of what is valid HTML. A tag and its attributes must form +/// syntactically valid HTML. Some tags, like `meta` do not accept content. +/// Hence, you must not provide a body for them. We may add more checks in the +/// future, so be sure that you are generating valid HTML when using this +/// function. +/// +/// Normally, Typst will generate `html`, `head`, and `body` tags for you. If +/// you instead create them with this function, Typst will omit its own tags. +/// +/// ```typ +/// #html.elem("div", attrs: (style: "background: aqua"))[ +/// A div with _Typst content_ inside! +/// ] +/// ``` +#[elem(name = "elem")] +pub struct HtmlElem { + /// The element's tag. + #[required] + pub tag: HtmlTag, + + /// The element's HTML attributes. + pub attrs: HtmlAttrs, + + /// The contents of the HTML element. + /// + /// The body can be arbitrary Typst content. + #[positional] + pub body: Option, } -/// The internal implementation of `html_document`. -#[comemo::memoize] -#[allow(clippy::too_many_arguments)] -fn html_document_impl( - routines: &Routines, - world: Tracked, - introspector: Tracked, - traced: Tracked, - sink: TrackedMut, - route: Tracked, - content: &Content, - styles: StyleChain, -) -> SourceResult { - let mut locator = Locator::root().split(); - let mut engine = Engine { - routines, - world, - introspector, - traced, - sink, - route: Route::extend(route).unnested(), - }; - - // Mark the external styles as "outside" so that they are valid at the page - // level. - let styles = styles.to_map().outside(); - let styles = StyleChain::new(&styles); - - let arenas = Arenas::default(); - let mut info = DocumentInfo::default(); - let children = (engine.routines.realize)( - RealizationKind::HtmlDocument(&mut info), - &mut engine, - &mut locator, - &arenas, - content, - styles, - )?; - - let output = handle_list(&mut engine, &mut locator, children.iter().copied())?; - let introspector = Introspector::html(&output); - let root = root_element(output, &info)?; - - Ok(HtmlDocument { info, root, introspector }) -} - -/// Produce HTML nodes from content. -#[typst_macros::time(name = "html fragment")] -pub fn html_fragment( - engine: &mut Engine, - content: &Content, - locator: Locator, - styles: StyleChain, -) -> SourceResult> { - html_fragment_impl( - engine.routines, - engine.world, - engine.introspector, - engine.traced, - TrackedMut::reborrow_mut(&mut engine.sink), - engine.route.track(), - content, - locator.track(), - styles, - ) -} - -/// The cached, internal implementation of [`html_fragment`]. -#[comemo::memoize] -#[allow(clippy::too_many_arguments)] -fn html_fragment_impl( - routines: &Routines, - world: Tracked, - introspector: Tracked, - traced: Tracked, - sink: TrackedMut, - route: Tracked, - content: &Content, - locator: Tracked, - styles: StyleChain, -) -> SourceResult> { - let link = LocatorLink::new(locator); - let mut locator = Locator::link(&link).split(); - let mut engine = Engine { - routines, - world, - introspector, - traced, - sink, - route: Route::extend(route), - }; - - engine.route.check_html_depth().at(content.span())?; - - let arenas = Arenas::default(); - let children = (engine.routines.realize)( - // No need to know about the `FragmentKind` because we handle both - // uniformly. - RealizationKind::HtmlFragment(&mut FragmentKind::Block), - &mut engine, - &mut locator, - &arenas, - content, - styles, - )?; - - handle_list(&mut engine, &mut locator, children.iter().copied()) -} - -/// Convert children into HTML nodes. -fn handle_list<'a>( - engine: &mut Engine, - locator: &mut SplitLocator, - children: impl IntoIterator>, -) -> SourceResult> { - let mut output = Vec::new(); - for (child, styles) in children { - handle(engine, child, locator, styles, &mut output)?; - } - Ok(output) -} - -/// Convert a child into HTML node(s). -fn handle( - engine: &mut Engine, - child: &Content, - locator: &mut SplitLocator, - styles: StyleChain, - output: &mut Vec, -) -> SourceResult<()> { - if let Some(elem) = child.to_packed::() { - output.push(HtmlNode::Tag(elem.tag.clone())); - } else if let Some(elem) = child.to_packed::() { - let mut children = vec![]; - if let Some(body) = elem.body(styles) { - children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; - } - let element = HtmlElement { - tag: elem.tag, - attrs: elem.attrs(styles).clone(), - children, - span: elem.span(), - }; - output.push(element.into()); - } else if let Some(elem) = child.to_packed::() { - let children = - html_fragment(engine, &elem.body, locator.next(&elem.span()), styles)?; - output.push( - HtmlElement::new(tag::p) - .with_children(children) - .spanned(elem.span()) - .into(), - ); - } else if let Some(elem) = child.to_packed::() { - // TODO: This is rather incomplete. - if let Some(body) = elem.body(styles) { - let children = - html_fragment(engine, body, locator.next(&elem.span()), styles)?; - output.push( - HtmlElement::new(tag::span) - .with_attr(attr::style, "display: inline-block;") - .with_children(children) - .spanned(elem.span()) - .into(), - ) - } - } else if let Some((elem, body)) = - child - .to_packed::() - .and_then(|elem| match elem.body(styles) { - Some(BlockBody::Content(body)) => Some((elem, body)), - _ => None, - }) - { - // TODO: This is rather incomplete. - let children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; - output.push( - HtmlElement::new(tag::div) - .with_children(children) - .spanned(elem.span()) - .into(), - ); - } else if child.is::() { - output.push(HtmlNode::text(' ', child.span())); - } else if let Some(elem) = child.to_packed::() { - output.push(HtmlNode::text(elem.text.clone(), elem.span())); - } else if let Some(elem) = child.to_packed::() { - output.push(HtmlElement::new(tag::br).spanned(elem.span()).into()); - } else if let Some(elem) = child.to_packed::() { - output.push(HtmlNode::text( - if elem.double(styles) { '"' } else { '\'' }, - child.span(), - )); - } else if let Some(elem) = child.to_packed::() { - let locator = locator.next(&elem.span()); - let style = TargetElem::set_target(Target::Paged).wrap(); - let frame = (engine.routines.layout_frame)( - engine, - &elem.body, - locator, - styles.chain(&style), - Region::new(Size::splat(Abs::inf()), Axes::splat(false)), - )?; - output.push(HtmlNode::Frame(frame)); - } else { - engine.sink.warn(warning!( - child.span(), - "{} was ignored during HTML export", - child.elem().name() - )); - } - Ok(()) -} - -/// Wrap the nodes in `` and `
` if they are not yet rooted, -/// supplying a suitable ``. -fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { - let head = head_element(info); - let body = match classify_output(output)? { - OutputKind::Html(element) => return Ok(element), - OutputKind::Body(body) => body, - OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), - }; - Ok(HtmlElement::new(tag::html).with_children(vec![head.into(), body.into()])) -} - -/// Generate a `` element. -fn head_element(info: &DocumentInfo) -> HtmlElement { - let mut children = vec![]; - - children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into()); - - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "viewport") - .with_attr(attr::content, "width=device-width, initial-scale=1") - .into(), - ); - - if let Some(title) = &info.title { - children.push( - HtmlElement::new(tag::title) - .with_children(vec![HtmlNode::Text(title.clone(), Span::detached())]) - .into(), - ); +impl HtmlElem { + /// Add an attribute to the element. + pub fn with_attr(mut self, attr: HtmlAttr, value: impl Into) -> Self { + self.attrs + .as_option_mut() + .get_or_insert_with(Default::default) + .push(attr, value); + self } - if let Some(description) = &info.description { - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "description") - .with_attr(attr::content, description.clone()) - .into(), - ); - } - - if !info.author.is_empty() { - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "authors") - .with_attr(attr::content, info.author.join(", ")) - .into(), - ) - } - - if !info.keywords.is_empty() { - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "keywords") - .with_attr(attr::content, info.keywords.join(", ")) - .into(), - ) - } - - HtmlElement::new(tag::head).with_children(children) -} - -/// Determine which kind of output the user generated. -fn classify_output(mut output: Vec) -> SourceResult { - let count = output.iter().filter(|node| !matches!(node, HtmlNode::Tag(_))).count(); - for node in &mut output { - let HtmlNode::Element(elem) = node else { continue }; - let tag = elem.tag; - let mut take = || std::mem::replace(elem, HtmlElement::new(tag::html)); - match (tag, count) { - (tag::html, 1) => return Ok(OutputKind::Html(take())), - (tag::body, 1) => return Ok(OutputKind::Body(take())), - (tag::html | tag::body, _) => bail!( - elem.span, - "`{}` element must be the only element in the document", - elem.tag, - ), - _ => {} + /// Adds CSS styles to an element. + fn with_styles(self, properties: css::Properties) -> Self { + if let Some(value) = properties.into_inline_styles() { + self.with_attr(attr::style, value) + } else { + self } } - Ok(OutputKind::Leafs(output)) } -/// What kinds of output the user generated. -enum OutputKind { - /// The user generated their own `` element. We do not need to supply - /// one. - Html(HtmlElement), - /// The user generate their own `` element. We do not need to supply - /// one, but need supply the `` element. - Body(HtmlElement), - /// The user generated leafs which we wrap in a `` and ``. - Leafs(Vec), +/// An element that lays out its content as an inline SVG. +/// +/// Sometimes, converting Typst content to HTML is not desirable. This can be +/// the case for plots and other content that relies on positioning and styling +/// to convey its message. +/// +/// This function allows you to use the Typst layout engine that would also be +/// used for PDF, SVG, and PNG export to render a part of your document exactly +/// how it would appear when exported in one of these formats. It embeds the +/// content as an inline SVG. +#[elem] +pub struct FrameElem { + /// The content that shall be laid out. + #[positional] + #[required] + pub body: Content, } diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs new file mode 100644 index 000000000..04a58ca47 --- /dev/null +++ b/crates/typst-html/src/rules.rs @@ -0,0 +1,454 @@ +use std::num::NonZeroUsize; + +use ecow::{eco_format, EcoVec}; +use typst_library::diag::warning; +use typst_library::foundations::{ + Content, NativeElement, NativeRuleMap, ShowFn, Smart, StyleChain, Target, +}; +use typst_library::introspection::{Counter, Locator}; +use typst_library::layout::resolve::{table_to_cellgrid, Cell, CellGrid, Entry}; +use typst_library::layout::{OuterVAlignment, Sizing}; +use typst_library::model::{ + Attribution, CiteElem, CiteGroup, Destination, EmphElem, EnumElem, FigureCaption, + FigureElem, HeadingElem, LinkElem, LinkTarget, ListElem, ParbreakElem, QuoteElem, + RefElem, StrongElem, TableCell, TableElem, TermsElem, +}; +use typst_library::text::{ + HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SpaceElem, StrikeElem, + SubElem, SuperElem, UnderlineElem, +}; +use typst_library::visualize::ImageElem; + +use crate::{attr, css, tag, FrameElem, HtmlAttrs, HtmlElem, HtmlTag}; + +/// Registers show rules for the [HTML target](Target::Html). +pub fn register(rules: &mut NativeRuleMap) { + use Target::{Html, Paged}; + + // Model. + rules.register(Html, STRONG_RULE); + rules.register(Html, EMPH_RULE); + rules.register(Html, LIST_RULE); + rules.register(Html, ENUM_RULE); + rules.register(Html, TERMS_RULE); + rules.register(Html, LINK_RULE); + rules.register(Html, HEADING_RULE); + rules.register(Html, FIGURE_RULE); + rules.register(Html, FIGURE_CAPTION_RULE); + rules.register(Html, QUOTE_RULE); + rules.register(Html, REF_RULE); + rules.register(Html, CITE_GROUP_RULE); + rules.register(Html, TABLE_RULE); + + // Text. + rules.register(Html, SUB_RULE); + rules.register(Html, SUPER_RULE); + rules.register(Html, UNDERLINE_RULE); + rules.register(Html, OVERLINE_RULE); + rules.register(Html, STRIKE_RULE); + rules.register(Html, HIGHLIGHT_RULE); + rules.register(Html, RAW_RULE); + rules.register(Html, RAW_LINE_RULE); + + // Visualize. + rules.register(Html, IMAGE_RULE); + + // For the HTML target, `html.frame` is a primitive. In the laid-out target, + // it should be a no-op so that nested frames don't break (things like `show + // math.equation: html.frame` can result in nested ones). + rules.register::(Paged, |elem, _, _| Ok(elem.body.clone())); +} + +const STRONG_RULE: ShowFn = |elem, _, _| { + Ok(HtmlElem::new(tag::strong) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const EMPH_RULE: ShowFn = |elem, _, _| { + Ok(HtmlElem::new(tag::em) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const LIST_RULE: ShowFn = |elem, _, styles| { + Ok(HtmlElem::new(tag::ul) + .with_body(Some(Content::sequence(elem.children.iter().map(|item| { + // Text in wide lists shall always turn into paragraphs. + let mut body = item.body.clone(); + if !elem.tight.get(styles) { + body += ParbreakElem::shared(); + } + HtmlElem::new(tag::li) + .with_body(Some(body)) + .pack() + .spanned(item.span()) + })))) + .pack() + .spanned(elem.span())) +}; + +const ENUM_RULE: ShowFn = |elem, _, styles| { + let mut ol = HtmlElem::new(tag::ol); + + if elem.reversed.get(styles) { + ol = ol.with_attr(attr::reversed, "reversed"); + } + + if let Some(n) = elem.start.get(styles).custom() { + ol = ol.with_attr(attr::start, eco_format!("{n}")); + } + + let body = Content::sequence(elem.children.iter().map(|item| { + let mut li = HtmlElem::new(tag::li); + if let Some(nr) = item.number.get(styles) { + li = li.with_attr(attr::value, eco_format!("{nr}")); + } + // Text in wide enums shall always turn into paragraphs. + let mut body = item.body.clone(); + if !elem.tight.get(styles) { + body += ParbreakElem::shared(); + } + li.with_body(Some(body)).pack().spanned(item.span()) + })); + + Ok(ol.with_body(Some(body)).pack().spanned(elem.span())) +}; + +const TERMS_RULE: ShowFn = |elem, _, styles| { + Ok(HtmlElem::new(tag::dl) + .with_body(Some(Content::sequence(elem.children.iter().flat_map(|item| { + // Text in wide term lists shall always turn into paragraphs. + let mut description = item.description.clone(); + if !elem.tight.get(styles) { + description += ParbreakElem::shared(); + } + + [ + HtmlElem::new(tag::dt) + .with_body(Some(item.term.clone())) + .pack() + .spanned(item.term.span()), + HtmlElem::new(tag::dd) + .with_body(Some(description)) + .pack() + .spanned(item.description.span()), + ] + })))) + .pack()) +}; + +const LINK_RULE: ShowFn = |elem, engine, _| { + let body = elem.body.clone(); + Ok(if let LinkTarget::Dest(Destination::Url(url)) = &elem.dest { + HtmlElem::new(tag::a) + .with_attr(attr::href, url.clone().into_inner()) + .with_body(Some(body)) + .pack() + .spanned(elem.span()) + } else { + engine.sink.warn(warning!( + elem.span(), + "non-URL links are not yet supported by HTML export" + )); + body + }) +}; + +const HEADING_RULE: ShowFn = |elem, engine, styles| { + let span = elem.span(); + + let mut realized = elem.body.clone(); + if let Some(numbering) = elem.numbering.get_ref(styles).as_ref() { + let location = elem.location().unwrap(); + let numbering = Counter::of(HeadingElem::ELEM) + .display_at_loc(engine, location, styles, numbering)? + .spanned(span); + realized = numbering + SpaceElem::shared().clone() + realized; + } + + // HTML's h1 is closer to a title element. There should only be one. + // Meanwhile, a level 1 Typst heading is a section heading. For this + // reason, levels are offset by one: A Typst level 1 heading becomes + // a `

`. + let level = elem.resolve_level(styles).get(); + Ok(if level >= 6 { + engine.sink.warn(warning!( + span, + "heading of level {} was transformed to \ +
, which is not \ + supported by all assistive technology", + level, level + 1; + hint: "HTML only supports

to

, not ", level + 1; + hint: "you may want to restructure your document so that \ + it doesn't contain deep headings" + )); + HtmlElem::new(tag::div) + .with_body(Some(realized)) + .with_attr(attr::role, "heading") + .with_attr(attr::aria_level, eco_format!("{}", level + 1)) + .pack() + .spanned(span) + } else { + let t = [tag::h2, tag::h3, tag::h4, tag::h5, tag::h6][level - 1]; + HtmlElem::new(t).with_body(Some(realized)).pack().spanned(span) + }) +}; + +const FIGURE_RULE: ShowFn = |elem, _, styles| { + let span = elem.span(); + let mut realized = elem.body.clone(); + + // Build the caption, if any. + if let Some(caption) = elem.caption.get_cloned(styles) { + realized = match caption.position.get(styles) { + OuterVAlignment::Top => caption.pack() + realized, + OuterVAlignment::Bottom => realized + caption.pack(), + }; + } + + // Ensure that the body is considered a paragraph. + realized += ParbreakElem::shared().clone().spanned(span); + + Ok(HtmlElem::new(tag::figure) + .with_body(Some(realized)) + .pack() + .spanned(span)) +}; + +const FIGURE_CAPTION_RULE: ShowFn = |elem, engine, styles| { + Ok(HtmlElem::new(tag::figcaption) + .with_body(Some(elem.realize(engine, styles)?)) + .pack() + .spanned(elem.span())) +}; + +const QUOTE_RULE: ShowFn = |elem, _, styles| { + let span = elem.span(); + let block = elem.block.get(styles); + + let mut realized = elem.body.clone(); + + if elem.quotes.get(styles).unwrap_or(!block) { + realized = QuoteElem::quoted(realized, styles); + } + + let attribution = elem.attribution.get_ref(styles); + + if block { + let mut blockquote = HtmlElem::new(tag::blockquote).with_body(Some(realized)); + if let Some(Attribution::Content(attribution)) = attribution { + if let Some(link) = attribution.to_packed::() { + if let LinkTarget::Dest(Destination::Url(url)) = &link.dest { + blockquote = + blockquote.with_attr(attr::cite, url.clone().into_inner()); + } + } + } + + realized = blockquote.pack().spanned(span); + + if let Some(attribution) = attribution.as_ref() { + realized += attribution.realize(span); + } + } else if let Some(Attribution::Label(label)) = attribution { + realized += SpaceElem::shared().clone(); + realized += CiteElem::new(*label).pack().spanned(span); + } + + Ok(realized) +}; + +const REF_RULE: ShowFn = |elem, engine, styles| elem.realize(engine, styles); + +const CITE_GROUP_RULE: ShowFn = |elem, engine, _| elem.realize(engine); + +const TABLE_RULE: ShowFn = |elem, engine, styles| { + // The locator is not used by HTML export, so we can just fabricate one. + let locator = Locator::root(); + Ok(show_cellgrid(table_to_cellgrid(elem, engine, locator, styles)?, styles)) +}; + +fn show_cellgrid(grid: CellGrid, styles: StyleChain) -> Content { + let elem = |tag, body| HtmlElem::new(tag).with_body(Some(body)).pack(); + let mut rows: Vec<_> = grid.entries.chunks(grid.non_gutter_column_count()).collect(); + + let tr = |tag, row: &[Entry]| { + let row = row + .iter() + .flat_map(|entry| entry.as_cell()) + .map(|cell| show_cell(tag, cell, styles)); + elem(tag::tr, Content::sequence(row)) + }; + + // TODO(subfooters): similarly to headers, take consecutive footers from + // the end for 'tfoot'. + let footer = grid.footer.map(|ft| { + let rows = rows.drain(ft.start..); + elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) + }); + + // Store all consecutive headers at the start in 'thead'. All remaining + // headers are just 'th' rows across the table body. + let mut consecutive_header_end = 0; + let first_mid_table_header = grid + .headers + .iter() + .take_while(|hd| { + let is_consecutive = hd.range.start == consecutive_header_end; + consecutive_header_end = hd.range.end; + is_consecutive + }) + .count(); + + let (y_offset, header) = if first_mid_table_header > 0 { + let removed_header_rows = + grid.headers.get(first_mid_table_header - 1).unwrap().range.end; + let rows = rows.drain(..removed_header_rows); + + ( + removed_header_rows, + Some(elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))), + ) + } else { + (0, None) + }; + + // TODO: Consider improving accessibility properties of multi-level headers + // inside tables in the future, e.g. indicating which columns they are + // relative to and so on. See also: + // https://www.w3.org/WAI/tutorials/tables/multi-level/ + let mut next_header = first_mid_table_header; + let mut body = + Content::sequence(rows.into_iter().enumerate().map(|(relative_y, row)| { + let y = relative_y + y_offset; + if let Some(current_header) = + grid.headers.get(next_header).filter(|h| h.range.contains(&y)) + { + if y + 1 == current_header.range.end { + next_header += 1; + } + + tr(tag::th, row) + } else { + tr(tag::td, row) + } + })); + + if header.is_some() || footer.is_some() { + body = elem(tag::tbody, body); + } + + let content = header.into_iter().chain(core::iter::once(body)).chain(footer); + elem(tag::table, Content::sequence(content)) +} + +fn show_cell(tag: HtmlTag, cell: &Cell, styles: StyleChain) -> Content { + let cell = cell.body.clone(); + let Some(cell) = cell.to_packed::() else { return cell }; + let mut attrs = HtmlAttrs::new(); + let span = |n: NonZeroUsize| (n != NonZeroUsize::MIN).then(|| n.to_string()); + if let Some(colspan) = span(cell.colspan.get(styles)) { + attrs.push(attr::colspan, colspan); + } + if let Some(rowspan) = span(cell.rowspan.get(styles)) { + attrs.push(attr::rowspan, rowspan); + } + HtmlElem::new(tag) + .with_body(Some(cell.body.clone())) + .with_attrs(attrs) + .pack() + .spanned(cell.span()) +} + +const SUB_RULE: ShowFn = |elem, _, _| { + Ok(HtmlElem::new(tag::sub) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const SUPER_RULE: ShowFn = |elem, _, _| { + Ok(HtmlElem::new(tag::sup) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const UNDERLINE_RULE: ShowFn = |elem, _, _| { + // Note: In modern HTML, `` is not the underline element, but + // rather an "Unarticulated Annotation" element (see HTML spec + // 4.5.22). Using `text-decoration` instead is recommended by MDN. + Ok(HtmlElem::new(tag::span) + .with_attr(attr::style, "text-decoration: underline") + .with_body(Some(elem.body.clone())) + .pack()) +}; + +const OVERLINE_RULE: ShowFn = |elem, _, _| { + Ok(HtmlElem::new(tag::span) + .with_attr(attr::style, "text-decoration: overline") + .with_body(Some(elem.body.clone())) + .pack()) +}; + +const STRIKE_RULE: ShowFn = + |elem, _, _| Ok(HtmlElem::new(tag::s).with_body(Some(elem.body.clone())).pack()); + +const HIGHLIGHT_RULE: ShowFn = + |elem, _, _| Ok(HtmlElem::new(tag::mark).with_body(Some(elem.body.clone())).pack()); + +const RAW_RULE: ShowFn = |elem, _, styles| { + let lines = elem.lines.as_deref().unwrap_or_default(); + + let mut seq = EcoVec::with_capacity((2 * lines.len()).saturating_sub(1)); + for (i, line) in lines.iter().enumerate() { + if i != 0 { + seq.push(LinebreakElem::shared().clone()); + } + + seq.push(line.clone().pack()); + } + + Ok(HtmlElem::new(if elem.block.get(styles) { tag::pre } else { tag::code }) + .with_body(Some(Content::sequence(seq))) + .pack() + .spanned(elem.span())) +}; + +const RAW_LINE_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); + +const IMAGE_RULE: ShowFn = |elem, engine, styles| { + let image = elem.decode(engine, styles)?; + + let mut attrs = HtmlAttrs::new(); + attrs.push(attr::src, typst_svg::convert_image_to_base64_url(&image)); + + if let Some(alt) = elem.alt.get_cloned(styles) { + attrs.push(attr::alt, alt); + } + + let mut inline = css::Properties::new(); + + // TODO: Exclude in semantic profile. + if let Some(value) = typst_svg::convert_image_scaling(image.scaling()) { + inline.push("image-rendering", value); + } + + // TODO: Exclude in semantic profile? + match elem.width.get(styles) { + Smart::Auto => {} + Smart::Custom(rel) => inline.push("width", css::rel(rel)), + } + + // TODO: Exclude in semantic profile? + match elem.height.get(styles) { + Sizing::Auto => {} + Sizing::Rel(rel) => inline.push("height", css::rel(rel)), + Sizing::Fr(_) => {} + } + + Ok(HtmlElem::new(tag::img).with_attrs(attrs).with_styles(inline).pack()) +}; diff --git a/crates/typst-html/src/tag.rs b/crates/typst-html/src/tag.rs new file mode 100644 index 000000000..89c50e1a8 --- /dev/null +++ b/crates/typst-html/src/tag.rs @@ -0,0 +1,271 @@ +//! Predefined constants for HTML tags. + +#![allow(non_upper_case_globals)] +#![allow(dead_code)] + +use crate::HtmlTag; + +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"); + +/// Whether this is a void tag whose associated element may not have +/// children. +pub fn is_void(tag: HtmlTag) -> bool { + matches!( + tag, + self::area + | self::base + | self::br + | self::col + | self::embed + | self::hr + | self::img + | self::input + | self::link + | self::meta + | self::source + | self::track + | self::wbr + ) +} + +/// Whether this is a tag containing raw text. +pub fn is_raw(tag: HtmlTag) -> bool { + matches!(tag, self::script | self::style) +} + +/// Whether this is a tag containing escapable raw text. +pub fn is_escapable_raw(tag: HtmlTag) -> bool { + matches!(tag, self::textarea | self::title) +} + +/// Whether an element is considered metadata. +pub fn is_metadata(tag: HtmlTag) -> bool { + matches!( + tag, + self::base + | self::link + | self::meta + | self::noscript + | self::script + | self::style + | self::template + | self::title + ) +} + +/// Whether nodes with the tag have the CSS property `display: block` by +/// default. +pub fn is_block_by_default(tag: HtmlTag) -> bool { + matches!( + tag, + self::html + | self::head + | self::body + | self::article + | self::aside + | self::h1 + | self::h2 + | self::h3 + | self::h4 + | self::h5 + | self::h6 + | self::hgroup + | self::nav + | self::section + | self::dd + | self::dl + | self::dt + | self::menu + | self::ol + | self::ul + | self::address + | self::blockquote + | self::dialog + | self::div + | self::fieldset + | self::figure + | self::figcaption + | self::footer + | self::form + | self::header + | self::hr + | self::legend + | self::main + | self::p + | self::pre + | self::search + ) +} + +/// Whether the element is inline-level as opposed to being block-level. +/// +/// Not sure whether this distinction really makes sense. But we somehow +/// need to decide what to put into automatic paragraphs. A `` +/// should merged into a paragraph created by realization, but a `
` +/// shouldn't. +/// +/// +/// +/// +pub fn is_inline_by_default(tag: HtmlTag) -> bool { + matches!( + tag, + self::abbr + | self::a + | self::bdi + | self::b + | self::br + | self::bdo + | self::code + | self::cite + | self::dfn + | self::data + | self::i + | self::em + | self::mark + | self::kbd + | self::rp + | self::q + | self::ruby + | self::rt + | self::samp + | self::s + | self::span + | self::small + | self::sub + | self::strong + | self::time + | self::sup + | self::var + | self::u + ) +} + +/// Whether nodes with the tag have the CSS property `display: table(-.*)?` +/// by default. +pub fn is_tabular_by_default(tag: HtmlTag) -> bool { + matches!( + tag, + self::table + | self::thead + | self::tbody + | self::tfoot + | self::tr + | self::th + | self::td + | self::caption + | self::col + | self::colgroup + ) +} diff --git a/crates/typst-library/src/html/typed.rs b/crates/typst-html/src/typed.rs similarity index 79% rename from crates/typst-library/src/html/typed.rs rename to crates/typst-html/src/typed.rs index 1e7c1ad6f..190ff4f16 100644 --- a/crates/typst-library/src/html/typed.rs +++ b/crates/typst-html/src/typed.rs @@ -11,19 +11,18 @@ 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::{ +use typst_library::diag::{bail, At, Hint, HintedStrResult, SourceResult}; +use typst_library::engine::Engine; +use typst_library::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; +use typst_library::layout::{Axes, Axis, Dir, Length}; +use typst_library::visualize::Color; +use typst_macros::cast; + +use crate::{css, tag, HtmlAttr, HtmlAttrs, HtmlElem, HtmlTag}; /// Hook up all typed HTML definitions. pub(super) fn define(html: &mut Scope) { @@ -127,12 +126,12 @@ fn construct(element: &'static data::ElemInfo, args: &mut Args) -> SourceResult< let tag = HtmlTag::constant(element.name); let mut elem = HtmlElem::new(tag); if !attrs.0.is_empty() { - elem.push_attrs(attrs); + elem.attrs.set(attrs); } if !tag::is_void(tag) { let body = args.eat::()?; - elem.push_body(body); + elem.body.set(body); } Ok(elem.into_value()) @@ -705,153 +704,6 @@ impl IntoAttr for SourceSize { } } -/// 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::*; diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs index c493da81a..e9fb8a7d7 100644 --- a/crates/typst-ide/src/analyze.rs +++ b/crates/typst-ide/src/analyze.rs @@ -1,8 +1,10 @@ +use std::collections::HashSet; + use comemo::Track; use ecow::{eco_vec, EcoString, EcoVec}; use typst::foundations::{Label, Styles, Value}; use typst::layout::PagedDocument; -use typst::model::BibliographyElem; +use typst::model::{BibliographyElem, FigureElem}; use typst::syntax::{ast, LinkedNode, SyntaxKind}; use crate::IdeWorld; @@ -66,17 +68,30 @@ pub fn analyze_import(world: &dyn IdeWorld, source: &LinkedNode) -> Option (Vec<(Label, Option)>, usize) { let mut output = vec![]; + let mut seen_labels = HashSet::new(); // Labels in the document. for elem in document.introspector.all() { let Some(label) = elem.label() else { continue }; + if !seen_labels.insert(label) { + continue; + } + let details = elem - .get_by_name("caption") - .or_else(|_| elem.get_by_name("body")) + .to_packed::() + .and_then(|figure| match figure.caption.as_option() { + Some(Some(caption)) => Some(caption.pack_ref()), + _ => None, + }) + .unwrap_or(elem) + .get_by_name("body") .ok() .and_then(|field| match field { Value::Content(content) => Some(content), diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 536423318..5b6d6fd97 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -76,7 +76,7 @@ pub struct Completion { } /// A kind of item that can be completed. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum CompletionKind { /// A syntactical structure. @@ -130,7 +130,14 @@ fn complete_markup(ctx: &mut CompletionContext) -> bool { return true; } - // Start of a reference: "@|" or "@he|". + // Start of a reference: "@|". + if ctx.leaf.kind() == SyntaxKind::Text && ctx.before.ends_with("@") { + ctx.from = ctx.cursor; + ctx.label_completions(); + return true; + } + + // An existing reference: "@he|". if ctx.leaf.kind() == SyntaxKind::RefMarker { ctx.from = ctx.leaf.offset() + 1; ctx.label_completions(); @@ -448,7 +455,7 @@ fn field_access_completions( match value { Value::Symbol(symbol) => { for modifier in symbol.modifiers() { - if let Ok(modified) = symbol.clone().modified(modifier) { + if let Ok(modified) = symbol.clone().modified((), modifier) { ctx.completions.push(Completion { kind: CompletionKind::Symbol(modified.get()), label: modifier.into(), @@ -1564,7 +1571,7 @@ mod tests { use typst::layout::PagedDocument; - use super::{autocomplete, Completion}; + use super::{autocomplete, Completion, CompletionKind}; use crate::tests::{FilePos, TestWorld, WorldLike}; /// Quote a string. @@ -1644,6 +1651,19 @@ mod tests { test_with_doc(world, pos, doc.as_ref()) } + #[track_caller] + fn test_with_addition( + initial_text: &str, + addition: &str, + pos: impl FilePos, + ) -> Response { + let mut world = TestWorld::new(initial_text); + let doc = typst::compile(&world).output.ok(); + let end = world.main.text().len(); + world.main.edit(end..end, addition); + test_with_doc(&world, pos, doc.as_ref()) + } + #[track_caller] fn test_with_doc( world: impl WorldLike, @@ -1709,6 +1729,30 @@ mod tests { .must_exclude(["bib"]); } + #[test] + fn test_autocomplete_ref_function() { + test_with_addition("x", " #ref(<)", -2).must_include(["test"]); + } + + #[test] + fn test_autocomplete_ref_shorthand() { + test_with_addition("x", " @", -1).must_include(["test"]); + } + + #[test] + fn test_autocomplete_ref_shorthand_with_partial_identifier() { + test_with_addition("x", " @te", -1).must_include(["test"]); + } + + #[test] + fn test_autocomplete_ref_identical_labels_returns_single_completion() { + let result = test_with_addition("x y", " @t", -1); + let completions = result.completions(); + let label_count = + completions.iter().filter(|c| c.kind == CompletionKind::Label).count(); + assert_eq!(label_count, 1); + } + /// Test what kind of brackets we autocomplete for function calls depending /// on the function and existing parens. #[test] diff --git a/crates/typst-ide/src/definition.rs b/crates/typst-ide/src/definition.rs index 69d702b3b..4c2b80cd4 100644 --- a/crates/typst-ide/src/definition.rs +++ b/crates/typst-ide/src/definition.rs @@ -72,7 +72,8 @@ pub fn definition( // Try to jump to the referenced content. DerefTarget::Ref(node) => { - let label = Label::new(PicoStr::intern(node.cast::()?.target())); + let label = Label::new(PicoStr::intern(node.cast::()?.target())) + .expect("unexpected empty reference"); let selector = Selector::Label(label); let elem = document?.introspector.query_first(&selector)?; return Some(Definition::Span(elem.span())); @@ -186,6 +187,6 @@ mod tests { #[test] fn test_definition_std() { - test("#table", 1, Side::After).must_be_value(typst::model::TableElem::elem()); + test("#table", 1, Side::After).must_be_value(typst::model::TableElem::ELEM); } } diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index dd5c230ad..168dfc9f2 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::{Feature, Library, World}; +use typst::{Feature, Library, LibraryExt, World}; use crate::IdeWorld; @@ -171,13 +171,11 @@ fn library() -> Library { let mut lib = typst::Library::builder() .with_features([Feature::Html].into_iter().collect()) .build(); + lib.styles.set(PageElem::width, Smart::Custom(Abs::pt(120.0).into())); + lib.styles.set(PageElem::height, Smart::Auto); lib.styles - .set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into()))); - lib.styles.set(PageElem::set_height(Smart::Auto)); - lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom( - Abs::pt(10.0).into(), - ))))); - lib.styles.set(TextElem::set_size(TextSize(Abs::pt(10.0).into()))); + .set(PageElem::margin, Margin::splat(Some(Smart::Custom(Abs::pt(10.0).into())))); + lib.styles.set(TextElem::size, TextSize(Abs::pt(10.0).into())); lib } diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index 528f679cf..e0d66a89b 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -378,4 +378,9 @@ mod tests { .with_source("other.typ", "#let f = (x) => 1"); test(&world, -4, Side::After).must_be_code("(..) => .."); } + + #[test] + fn test_tooltip_reference() { + test("#figure(caption: [Hi])[] @f", -1, Side::Before).must_be_text("Hi"); + } } diff --git a/crates/typst-kit/src/package.rs b/crates/typst-kit/src/package.rs index 584ec83c0..e62e843cd 100644 --- a/crates/typst-kit/src/package.rs +++ b/crates/typst-kit/src/package.rs @@ -199,7 +199,7 @@ impl PackageStorage { // The place at which the specific package version will live in the end. let package_dir = base_dir.join(format!("{}", spec.version)); - // To prevent multiple Typst instances from interferring, we download + // To prevent multiple Typst instances from interfering, we download // into a temporary directory first and then move this directory to // its final destination. // diff --git a/crates/typst-layout/Cargo.toml b/crates/typst-layout/Cargo.toml index cc355a3db..2c314e5c5 100644 --- a/crates/typst-layout/Cargo.toml +++ b/crates/typst-layout/Cargo.toml @@ -21,6 +21,7 @@ typst-timing = { workspace = true } typst-utils = { workspace = true } az = { workspace = true } bumpalo = { workspace = true } +codex = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } hypher = { workspace = true } diff --git a/crates/typst-layout/src/flow/block.rs b/crates/typst-layout/src/flow/block.rs index 6c2c3923d..d6cfe3a9e 100644 --- a/crates/typst-layout/src/flow/block.rs +++ b/crates/typst-layout/src/flow/block.rs @@ -24,15 +24,15 @@ pub fn layout_single_block( region: Region, ) -> SourceResult { // Fetch sizing properties. - let width = elem.width(styles); - let height = elem.height(styles); - let inset = elem.inset(styles).unwrap_or_default(); + let width = elem.width.get(styles); + let height = elem.height.get(styles); + let inset = elem.inset.resolve(styles).unwrap_or_default(); // Build the pod regions. let pod = unbreakable_pod(&width.into(), &height, &inset, styles, region.size); // Layout the body. - let body = elem.body(styles); + let body = elem.body.get_ref(styles); let mut frame = match body { // If we have no body, just create one frame. Its size will be // adjusted below. @@ -73,18 +73,19 @@ pub fn layout_single_block( } // Prepare fill and stroke. - let fill = elem.fill(styles); + let fill = elem.fill.get_cloned(styles); let stroke = elem - .stroke(styles) + .stroke + .resolve(styles) .unwrap_or_default() .map(|s| s.map(Stroke::unwrap_or_default)); // Only fetch these if necessary (for clipping or filling/stroking). - let outset = LazyCell::new(|| elem.outset(styles).unwrap_or_default()); - let radius = LazyCell::new(|| elem.radius(styles).unwrap_or_default()); + let outset = LazyCell::new(|| elem.outset.resolve(styles).unwrap_or_default()); + let radius = LazyCell::new(|| elem.radius.resolve(styles).unwrap_or_default()); // Clip the contents, if requested. - if elem.clip(styles) { + if elem.clip.get(styles) { frame.clip(clip_rect(frame.size(), &radius, &stroke, &outset)); } @@ -111,9 +112,9 @@ pub fn layout_multi_block( regions: Regions, ) -> SourceResult { // Fetch sizing properties. - let width = elem.width(styles); - let height = elem.height(styles); - let inset = elem.inset(styles).unwrap_or_default(); + let width = elem.width.get(styles); + let height = elem.height.get(styles); + let inset = elem.inset.resolve(styles).unwrap_or_default(); // Allocate a small vector for backlogs. let mut buf = SmallVec::<[Abs; 2]>::new(); @@ -122,7 +123,7 @@ pub fn layout_multi_block( let pod = breakable_pod(&width.into(), &height, &inset, styles, regions, &mut buf); // Layout the body. - let body = elem.body(styles); + let body = elem.body.get_ref(styles); let mut fragment = match body { // If we have no body, just create one frame plus one per backlog // region. We create them zero-sized; if necessary, their size will @@ -188,18 +189,19 @@ pub fn layout_multi_block( }; // Prepare fill and stroke. - let fill = elem.fill(styles); + let fill = elem.fill.get_ref(styles); let stroke = elem - .stroke(styles) + .stroke + .resolve(styles) .unwrap_or_default() .map(|s| s.map(Stroke::unwrap_or_default)); // Only fetch these if necessary (for clipping or filling/stroking). - let outset = LazyCell::new(|| elem.outset(styles).unwrap_or_default()); - let radius = LazyCell::new(|| elem.radius(styles).unwrap_or_default()); + let outset = LazyCell::new(|| elem.outset.resolve(styles).unwrap_or_default()); + let radius = LazyCell::new(|| elem.radius.resolve(styles).unwrap_or_default()); // Fetch/compute these outside of the loop. - let clip = elem.clip(styles); + let clip = elem.clip.get(styles); let has_fill_or_stroke = fill.is_some() || stroke.iter().any(Option::is_some); let has_inset = !inset.is_zero(); let is_explicit = matches!(body, None | Some(BlockBody::Content(_))); diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs index 2c14f7a37..76268b590 100644 --- a/crates/typst-layout/src/flow/collect.rs +++ b/crates/typst-layout/src/flow/collect.rs @@ -89,7 +89,7 @@ impl<'a> Collector<'a, '_, '_> { } else if child.is::() { self.output.push(Child::Flush); } else if let Some(elem) = child.to_packed::() { - self.output.push(Child::Break(elem.weak(styles))); + self.output.push(Child::Break(elem.weak.get(styles))); } else if child.is::() { bail!( child.span(), "pagebreaks are not allowed inside of containers"; @@ -132,7 +132,7 @@ impl<'a> Collector<'a, '_, '_> { self.output.push(Child::Tag(&elem.tag)); } - let leading = ParElem::leading_in(styles); + let leading = styles.resolve(ParElem::leading); self.lines(lines, leading, styles); for (c, _) in &self.children[end..] { @@ -146,7 +146,9 @@ impl<'a> Collector<'a, '_, '_> { /// Collect vertical spacing into a relative or fractional child. fn v(&mut self, elem: &'a Packed, styles: StyleChain<'a>) { self.output.push(match elem.amount { - Spacing::Rel(rel) => Child::Rel(rel.resolve(styles), elem.weak(styles) as u8), + Spacing::Rel(rel) => { + Child::Rel(rel.resolve(styles), elem.weak.get(styles) as u8) + } Spacing::Fr(fr) => Child::Fr(fr), }); } @@ -169,8 +171,8 @@ impl<'a> Collector<'a, '_, '_> { )? .into_frames(); - let spacing = elem.spacing(styles); - let leading = elem.leading(styles); + let spacing = elem.spacing.resolve(styles); + let leading = elem.leading.resolve(styles); self.output.push(Child::Rel(spacing.into(), 4)); @@ -184,8 +186,8 @@ impl<'a> Collector<'a, '_, '_> { /// Collect laid-out lines. fn lines(&mut self, lines: Vec, leading: Abs, styles: StyleChain<'a>) { - let align = AlignElem::alignment_in(styles).resolve(styles); - let costs = TextElem::costs_in(styles); + let align = styles.resolve(AlignElem::alignment); + let costs = styles.get(TextElem::costs); // Determine whether to prevent widow and orphans. let len = lines.len(); @@ -231,23 +233,23 @@ impl<'a> Collector<'a, '_, '_> { /// whether it is breakable. fn block(&mut self, elem: &'a Packed, styles: StyleChain<'a>) { let locator = self.locator.next(&elem.span()); - let align = AlignElem::alignment_in(styles).resolve(styles); + let align = styles.resolve(AlignElem::alignment); let alone = self.children.len() == 1; - let sticky = elem.sticky(styles); - let breakable = elem.breakable(styles); - let fr = match elem.height(styles) { + let sticky = elem.sticky.get(styles); + let breakable = elem.breakable.get(styles); + let fr = match elem.height.get(styles) { Sizing::Fr(fr) => Some(fr), _ => None, }; - let fallback = LazyCell::new(|| ParElem::spacing_in(styles)); + let fallback = LazyCell::new(|| styles.resolve(ParElem::spacing)); let spacing = |amount| match amount { Smart::Auto => Child::Rel((*fallback).into(), 4), Smart::Custom(Spacing::Rel(rel)) => Child::Rel(rel.resolve(styles), 3), Smart::Custom(Spacing::Fr(fr)) => Child::Fr(fr), }; - self.output.push(spacing(elem.above(styles))); + self.output.push(spacing(elem.above.get(styles))); if !breakable || fr.is_some() { self.output.push(Child::Single(self.boxed(SingleChild { @@ -272,7 +274,7 @@ impl<'a> Collector<'a, '_, '_> { }))); }; - self.output.push(spacing(elem.below(styles))); + self.output.push(spacing(elem.below.get(styles))); self.par_situation = ParSituation::Other; } @@ -282,13 +284,13 @@ impl<'a> Collector<'a, '_, '_> { elem: &'a Packed, styles: StyleChain<'a>, ) -> SourceResult<()> { - let alignment = elem.alignment(styles); + let alignment = elem.alignment.get(styles); let align_x = alignment.map_or(FixedAlignment::Center, |align| { align.x().unwrap_or_default().resolve(styles) }); let align_y = alignment.map(|align| align.y().map(|y| y.resolve(styles))); - let scope = elem.scope(styles); - let float = elem.float(styles); + let scope = elem.scope.get(styles); + let float = elem.float.get(styles); match (float, align_y) { (true, Smart::Custom(None | Some(FixedAlignment::Center))) => bail!( @@ -312,8 +314,8 @@ impl<'a> Collector<'a, '_, '_> { } let locator = self.locator.next(&elem.span()); - let clearance = elem.clearance(styles); - let delta = Axes::new(elem.dx(styles), elem.dy(styles)).resolve(styles); + let clearance = elem.clearance.resolve(styles); + let delta = Axes::new(elem.dx.get(styles), elem.dy.get(styles)).resolve(styles); self.output.push(Child::Placed(self.boxed(PlacedChild { align_x, align_y, @@ -631,7 +633,7 @@ impl PlacedChild<'_> { pub fn layout(&self, engine: &mut Engine, base: Size) -> SourceResult { self.cell.get_or_init(base, |base| { let align = self.alignment.unwrap_or_else(|| Alignment::CENTER); - let aligned = AlignElem::set_alignment(align).wrap(); + let aligned = AlignElem::alignment.set(align).wrap(); let styles = self.styles.chain(&aligned); let mut frame = layout_and_modify(styles, |styles| { diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs index 54dc487a3..ed514a248 100644 --- a/crates/typst-layout/src/flow/compose.rs +++ b/crates/typst-layout/src/flow/compose.rs @@ -851,7 +851,7 @@ fn layout_line_number_reset( config: &Config, locator: &mut SplitLocator, ) -> SourceResult { - let counter = Counter::of(ParLineMarker::elem()); + let counter = Counter::of(ParLineMarker::ELEM); let update = CounterUpdate::Set(CounterState::init(false)); let content = counter.update(Span::detached(), update); crate::layout_frame( @@ -879,7 +879,7 @@ fn layout_line_number( locator: &mut SplitLocator, numbering: &Numbering, ) -> SourceResult { - let counter = Counter::of(ParLineMarker::elem()); + let counter = Counter::of(ParLineMarker::ELEM); let update = CounterUpdate::Step(NonZeroUsize::ONE); let numbering = Smart::Custom(numbering.clone()); diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index cba228bcd..cb029dce8 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -98,8 +98,8 @@ pub fn layout_columns( locator.track(), styles, regions, - elem.count(styles), - elem.gutter(styles), + elem.count.get(styles), + elem.gutter.resolve(styles), ) } @@ -143,7 +143,7 @@ fn layout_fragment_impl( let mut kind = FragmentKind::Block; let arenas = Arenas::default(); let children = (engine.routines.realize)( - RealizationKind::LayoutFragment(&mut kind), + RealizationKind::LayoutFragment { kind: &mut kind }, &mut engine, &mut locator, &arenas, @@ -251,22 +251,22 @@ fn configuration<'x>( let gutter = column_gutter.relative_to(regions.base().x); let width = (regions.size.x - gutter * (count - 1) as f64) / count as f64; - let dir = TextElem::dir_in(shared); + let dir = shared.resolve(TextElem::dir); ColumnConfig { count, width, gutter, dir } }, footnote: FootnoteConfig { - separator: FootnoteEntry::separator_in(shared), - clearance: FootnoteEntry::clearance_in(shared), - gap: FootnoteEntry::gap_in(shared), + separator: shared.get_cloned(FootnoteEntry::separator), + clearance: shared.resolve(FootnoteEntry::clearance), + gap: shared.resolve(FootnoteEntry::gap), expand: regions.expand.x, }, line_numbers: (mode == FlowMode::Root).then(|| LineNumberConfig { - scope: ParLine::numbering_scope_in(shared), + scope: shared.get(ParLine::numbering_scope), default_clearance: { - let width = if PageElem::flipped_in(shared) { - PageElem::height_in(shared) + let width = if shared.get(PageElem::flipped) { + shared.resolve(PageElem::height) } else { - PageElem::width_in(shared) + shared.resolve(PageElem::width) }; // Clamp below is safe (min <= max): if the font size is diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 42fe38dbe..d4f11f470 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -249,7 +249,7 @@ impl<'a> GridLayouter<'a> { rowspans: vec![], finished: vec![], finished_header_rows: vec![], - is_rtl: TextElem::dir_in(styles) == Dir::RTL, + is_rtl: styles.resolve(TextElem::dir) == Dir::RTL, repeating_headers: vec![], upcoming_headers: &grid.headers, pending_headers: Default::default(), diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index a8f4a0c81..d4fd121ec 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -1,18 +1,11 @@ -use std::ffi::OsStr; - -use typst_library::diag::{warning, At, LoadedWithin, SourceResult, StrResult}; +use typst_library::diag::SourceResult; use typst_library::engine::Engine; -use typst_library::foundations::{Bytes, Derived, Packed, Smart, StyleChain}; +use typst_library::foundations::{Packed, StyleChain}; use typst_library::introspection::Locator; use typst_library::layout::{ Abs, Axes, FixedAlignment, Frame, FrameItem, Point, Region, Size, }; -use typst_library::loading::DataSource; -use typst_library::text::families; -use typst_library::visualize::{ - Curve, ExchangeFormat, Image, ImageElem, ImageFit, ImageFormat, ImageKind, - RasterImage, SvgImage, VectorFormat, -}; +use typst_library::visualize::{Curve, Image, ImageElem, ImageFit}; /// Layout the image. #[typst_macros::time(span = elem.span())] @@ -23,53 +16,7 @@ pub fn layout_image( styles: StyleChain, region: Region, ) -> SourceResult { - let span = elem.span(); - - // Take the format that was explicitly defined, or parse the extension, - // or try to detect the format. - let Derived { source, derived: loaded } = &elem.source; - let format = match elem.format(styles) { - Smart::Custom(v) => v, - Smart::Auto => determine_format(source, &loaded.data).at(span)?, - }; - - // Warn the user if the image contains a foreign object. Not perfect - // because the svg could also be encoded, but that's an edge case. - if format == ImageFormat::Vector(VectorFormat::Svg) { - let has_foreign_object = - memchr::memmem::find(&loaded.data, b" ImageKind::Raster( - RasterImage::new( - loaded.data.clone(), - format, - elem.icc(styles).as_ref().map(|icc| icc.derived.clone()), - ) - .at(span)?, - ), - ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg( - SvgImage::with_fonts( - loaded.data.clone(), - engine.world, - &families(styles).map(|f| f.as_str()).collect::>(), - ) - .within(loaded)?, - ), - }; - - let image = Image::new(kind, elem.alt(styles), elem.scaling(styles)); + let image = elem.decode(engine, styles)?; // Determine the image's pixel aspect ratio. let pxw = image.width(); @@ -106,7 +53,7 @@ pub fn layout_image( }; // Compute the actual size of the fitted image. - let fit = elem.fit(styles); + let fit = elem.fit.get(styles); let fitted = match fit { ImageFit::Cover | ImageFit::Contain => { if wide == (fit == ImageFit::Contain) { @@ -122,7 +69,7 @@ pub fn layout_image( // the frame to the target size, center aligning the image in the // process. let mut frame = Frame::soft(fitted); - frame.push(Point::zero(), FrameItem::Image(image, fitted, span)); + frame.push(Point::zero(), FrameItem::Image(image, fitted, elem.span())); frame.resize(target, Axes::splat(FixedAlignment::Center)); // Create a clipping group if only part of the image should be visible. @@ -132,25 +79,3 @@ pub fn layout_image( Ok(frame) } - -/// Try to determine the image format based on the data. -fn determine_format(source: &DataSource, data: &Bytes) -> StrResult { - if let DataSource::Path(path) = source { - let ext = std::path::Path::new(path.as_str()) - .extension() - .and_then(OsStr::to_str) - .unwrap_or_default() - .to_lowercase(); - - match ext.as_str() { - "png" => return Ok(ExchangeFormat::Png.into()), - "jpg" | "jpeg" => return Ok(ExchangeFormat::Jpg.into()), - "gif" => return Ok(ExchangeFormat::Gif.into()), - "svg" | "svgz" => return Ok(VectorFormat::Svg.into()), - "webp" => return Ok(ExchangeFormat::Webp.into()), - _ => {} - } - } - - Ok(ImageFormat::detect(data).ok_or("unknown image format")?) -} diff --git a/crates/typst-layout/src/inline/box.rs b/crates/typst-layout/src/inline/box.rs index e21928d3c..65b025334 100644 --- a/crates/typst-layout/src/inline/box.rs +++ b/crates/typst-layout/src/inline/box.rs @@ -21,15 +21,15 @@ pub fn layout_box( region: Size, ) -> SourceResult { // Fetch sizing properties. - let width = elem.width(styles); - let height = elem.height(styles); - let inset = elem.inset(styles).unwrap_or_default(); + let width = elem.width.get(styles); + let height = elem.height.get(styles); + let inset = elem.inset.resolve(styles).unwrap_or_default(); // Build the pod region. let pod = unbreakable_pod(&width, &height.into(), &inset, styles, region); // Layout the body. - let mut frame = match elem.body(styles) { + let mut frame = match elem.body.get_ref(styles) { // If we have no body, just create an empty frame. If necessary, // its size will be adjusted below. None => Frame::hard(Size::zero()), @@ -50,18 +50,19 @@ pub fn layout_box( } // Prepare fill and stroke. - let fill = elem.fill(styles); + let fill = elem.fill.get_cloned(styles); let stroke = elem - .stroke(styles) + .stroke + .resolve(styles) .unwrap_or_default() .map(|s| s.map(Stroke::unwrap_or_default)); // Only fetch these if necessary (for clipping or filling/stroking). - let outset = LazyCell::new(|| elem.outset(styles).unwrap_or_default()); - let radius = LazyCell::new(|| elem.radius(styles).unwrap_or_default()); + let outset = LazyCell::new(|| elem.outset.resolve(styles).unwrap_or_default()); + let radius = LazyCell::new(|| elem.radius.resolve(styles).unwrap_or_default()); // Clip the contents, if requested. - if elem.clip(styles) { + if elem.clip.get(styles) { frame.clip(clip_rect(frame.size(), &radius, &stroke, &outset)); } @@ -78,7 +79,7 @@ pub fn layout_box( // Apply baseline shift. Do this after setting the size and applying the // inset, so that a relative shift is resolved relative to the final // height. - let shift = elem.baseline(styles).relative_to(frame.height()); + let shift = elem.baseline.resolve(styles).relative_to(frame.height()); if !shift.is_zero() { frame.set_baseline(frame.baseline() - shift); } diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs index 5a1b7b4fc..2744b31e0 100644 --- a/crates/typst-layout/src/inline/collect.rs +++ b/crates/typst-layout/src/inline/collect.rs @@ -144,7 +144,7 @@ pub fn collect<'a>( collector.push_text(" ", styles); } else if let Some(elem) = child.to_packed::() { collector.build_text(styles, |full| { - let dir = TextElem::dir_in(styles); + let dir = styles.resolve(TextElem::dir); if dir != config.dir { // Insert "Explicit Directional Embedding". match dir { @@ -154,7 +154,7 @@ pub fn collect<'a>( } } - if let Some(case) = TextElem::case_in(styles) { + if let Some(case) = styles.get(TextElem::case) { full.push_str(&case.apply(&elem.text)); } else { full.push_str(&elem.text); @@ -174,20 +174,22 @@ pub fn collect<'a>( Spacing::Fr(fr) => Item::Fractional(fr, None), Spacing::Rel(rel) => Item::Absolute( rel.resolve(styles).relative_to(region.x), - elem.weak(styles), + elem.weak.get(styles), ), }); } else if let Some(elem) = child.to_packed::() { - collector - .push_text(if elem.justify(styles) { "\u{2028}" } else { "\n" }, styles); + collector.push_text( + if elem.justify.get(styles) { "\u{2028}" } else { "\n" }, + styles, + ); } else if let Some(elem) = child.to_packed::() { - let double = elem.double(styles); - if elem.enabled(styles) { + let double = elem.double.get(styles); + if elem.enabled.get(styles) { let quotes = SmartQuotes::get( - elem.quotes(styles), - TextElem::lang_in(styles), - TextElem::region_in(styles), - elem.alternative(styles), + elem.quotes.get_ref(styles), + styles.get(TextElem::lang), + styles.get(TextElem::region), + elem.alternative.get(styles), ); let before = collector.full.chars().rev().find(|&c| !is_default_ignorable(c)); @@ -206,7 +208,7 @@ pub fn collect<'a>( } InlineItem::Frame(mut frame) => { frame.modify(&FrameModifiers::get_in(styles)); - apply_baseline_shift(&mut frame, styles); + apply_shift(&engine.world, &mut frame, styles); collector.push_item(Item::Frame(frame)); } } @@ -215,13 +217,13 @@ pub fn collect<'a>( collector.push_item(Item::Skip(POP_ISOLATE)); } else if let Some(elem) = child.to_packed::() { let loc = locator.next(&elem.span()); - if let Sizing::Fr(v) = elem.width(styles) { + if let Sizing::Fr(v) = elem.width.get(styles) { collector.push_item(Item::Fractional(v, Some((elem, loc, styles)))); } else { let mut frame = layout_and_modify(styles, |styles| { layout_box(elem, engine, loc, styles, region) })?; - apply_baseline_shift(&mut frame, styles); + apply_shift(&engine.world, &mut frame, styles); collector.push_item(Item::Frame(frame)); } } else if let Some(elem) = child.to_packed::() { diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index 659d33f4a..58162d12b 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -2,10 +2,11 @@ use std::fmt::{self, Debug, Formatter}; use std::ops::{Deref, DerefMut}; use typst_library::engine::Engine; +use typst_library::foundations::Resolve; use typst_library::introspection::{SplitLocator, Tag}; use typst_library::layout::{Abs, Dir, Em, Fr, Frame, FrameItem, Point}; use typst_library::model::ParLineMarker; -use typst_library::text::{Lang, TextElem}; +use typst_library::text::{variant, Lang, TextElem}; use typst_utils::Numeric; use super::*; @@ -219,7 +220,7 @@ fn collect_items<'a>( // Add fallback text to expand the line height, if necessary. if !items.iter().any(|item| matches!(item, Item::Text(_))) { if let Some(fallback) = fallback { - items.push(fallback); + items.push(fallback, usize::MAX); } } @@ -270,10 +271,10 @@ fn collect_range<'a>( items: &mut Items<'a>, fallback: &mut Option>, ) { - for (subrange, item) in p.slice(range.clone()) { + for (idx, (subrange, item)) in p.slice(range.clone()).enumerate() { // All non-text items are just kept, they can't be split. let Item::Text(shaped) = item else { - items.push(item); + items.push(item, idx); continue; }; @@ -293,10 +294,10 @@ fn collect_range<'a>( } else if split { // When the item is split in half, reshape it. let reshaped = shaped.reshape(engine, sliced); - items.push(Item::Text(reshaped)); + items.push(Item::Text(reshaped), idx); } else { // When the item is fully contained, just keep it. - items.push(item); + items.push(item, idx); } } } @@ -330,7 +331,7 @@ fn adjust_cj_at_line_start(p: &Preparation, items: &mut Items) { let glyph = shaped.glyphs.to_mut().first_mut().unwrap(); let shrink = glyph.shrinkability().0; glyph.shrink_left(shrink); - shaped.width -= shrink.at(shaped.size); + shaped.width -= shrink.at(glyph.size); } else if p.config.cjk_latin_spacing && glyph.is_cj_script() && glyph.x_offset > Em::zero() @@ -342,7 +343,7 @@ fn adjust_cj_at_line_start(p: &Preparation, items: &mut Items) { glyph.x_advance -= shrink; glyph.x_offset = Em::zero(); glyph.adjustability.shrinkability.0 = Em::zero(); - shaped.width -= shrink.at(shaped.size); + shaped.width -= shrink.at(glyph.size); } } @@ -360,7 +361,7 @@ fn adjust_cj_at_line_end(p: &Preparation, items: &mut Items) { let shrink = glyph.shrinkability().1; let punct = shaped.glyphs.to_mut().last_mut().unwrap(); punct.shrink_right(shrink); - shaped.width -= shrink.at(shaped.size); + shaped.width -= shrink.at(punct.size); } else if p.config.cjk_latin_spacing && glyph.is_cj_script() && (glyph.x_advance - glyph.x_offset) > Em::one() @@ -371,7 +372,7 @@ fn adjust_cj_at_line_end(p: &Preparation, items: &mut Items) { let glyph = shaped.glyphs.to_mut().last_mut().unwrap(); glyph.x_advance -= shrink; glyph.adjustability.shrinkability.1 = Em::zero(); - shaped.width -= shrink.at(shaped.size); + shaped.width -= shrink.at(glyph.size); } } @@ -412,9 +413,31 @@ fn should_repeat_hyphen(pred_line: &Line, text: &str) -> bool { } } -/// Apply the current baseline shift to a frame. -pub fn apply_baseline_shift(frame: &mut Frame, styles: StyleChain) { - frame.translate(Point::with_y(TextElem::baseline_in(styles))); +/// Apply the current baseline shift and italic compensation to a frame. +pub fn apply_shift<'a>( + world: &Tracked<'a, dyn World + 'a>, + frame: &mut Frame, + styles: StyleChain, +) { + let mut baseline = styles.resolve(TextElem::baseline); + let mut compensation = Abs::zero(); + if let Some(scripts) = styles.get_ref(TextElem::shift_settings) { + let font_metrics = styles + .get_ref(TextElem::font) + .into_iter() + .find_map(|family| { + world + .book() + .select(family.as_str(), variant(styles)) + .and_then(|id| world.font(id)) + }) + .map_or(*scripts.kind.default_metrics(), |f| { + *scripts.kind.read_metrics(f.metrics()) + }); + baseline -= scripts.shift.unwrap_or(font_metrics.vertical_offset).resolve(styles); + compensation += font_metrics.horizontal_offset.resolve(styles); + } + frame.translate(Point::new(compensation, baseline)); } /// Commit to a line and build its frame. @@ -441,10 +464,10 @@ pub fn commit( if let Some(Item::Text(text)) = line.items.first() { if let Some(glyph) = text.glyphs.first() { if !text.dir.is_positive() - && TextElem::overhang_in(text.styles) + && text.styles.get(TextElem::overhang) && (line.items.len() > 1 || text.glyphs.len() > 1) { - let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + let amount = overhang(glyph.c) * glyph.x_advance.at(glyph.size); offset -= amount; remaining += amount; } @@ -455,10 +478,10 @@ pub fn commit( if let Some(Item::Text(text)) = line.items.last() { if let Some(glyph) = text.glyphs.last() { if text.dir.is_positive() - && TextElem::overhang_in(text.styles) + && text.styles.get(TextElem::overhang) && (line.items.len() > 1 || text.glyphs.len() > 1) { - let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + let amount = overhang(glyph.c) * glyph.x_advance.at(glyph.size); remaining += amount; } } @@ -499,16 +522,16 @@ pub fn commit( // Build the frames and determine the height and baseline. let mut frames = vec![]; - for item in line.items.iter() { - let mut push = |offset: &mut Abs, frame: Frame| { + for &(idx, ref item) in line.items.indexed_iter() { + let mut push = |offset: &mut Abs, frame: Frame, idx: usize| { let width = frame.width(); top.set_max(frame.baseline()); bottom.set_max(frame.size().y - frame.baseline()); - frames.push((*offset, frame)); + frames.push((*offset, frame, idx)); *offset += width; }; - match item { + match &**item { Item::Absolute(v, _) => { offset += *v; } @@ -519,8 +542,8 @@ pub fn commit( let mut frame = layout_and_modify(*styles, |styles| { layout_box(elem, engine, loc.relayout(), styles, region) })?; - apply_baseline_shift(&mut frame, *styles); - push(&mut offset, frame); + apply_shift(&engine.world, &mut frame, *styles); + push(&mut offset, frame, idx); } else { offset += amount; } @@ -532,15 +555,15 @@ pub fn commit( justification_ratio, extra_justification, ); - push(&mut offset, frame); + push(&mut offset, frame, idx); } Item::Frame(frame) => { - push(&mut offset, frame.clone()); + push(&mut offset, frame.clone(), idx); } Item::Tag(tag) => { let mut frame = Frame::soft(Size::zero()); frame.push(Point::zero(), FrameItem::Tag((*tag).clone())); - frames.push((offset, frame)); + frames.push((offset, frame, idx)); } Item::Skip(_) => {} } @@ -559,8 +582,13 @@ pub fn commit( add_par_line_marker(&mut output, marker, engine, locator, top); } + // Ensure that the final frame's items are in logical order rather than in + // visual order. This is important because it affects the order of elements + // during introspection and thus things like counters. + frames.sort_unstable_by_key(|(_, _, idx)| *idx); + // Construct the line's frame. - for (offset, frame) in frames { + for (offset, frame, _) in frames { let x = offset + p.config.align.position(remaining); let y = top - frame.baseline(); output.push_frame(Point::new(x, y), frame); @@ -627,7 +655,7 @@ fn overhang(c: char) -> f64 { } /// A collection of owned or borrowed inline items. -pub struct Items<'a>(Vec>); +pub struct Items<'a>(Vec<(usize, ItemEntry<'a>)>); impl<'a> Items<'a> { /// Create empty items. @@ -636,33 +664,38 @@ impl<'a> Items<'a> { } /// Push a new item. - pub fn push(&mut self, entry: impl Into>) { - self.0.push(entry.into()); + pub fn push(&mut self, entry: impl Into>, idx: usize) { + self.0.push((idx, entry.into())); } - /// Iterate over the items + /// Iterate over the items. pub fn iter(&self) -> impl Iterator> { - self.0.iter().map(|item| &**item) + self.0.iter().map(|(_, item)| &**item) + } + + /// Iterate over the items with indices + pub fn indexed_iter(&self) -> impl Iterator)> { + self.0.iter() } /// Access the first item. pub fn first(&self) -> Option<&Item<'a>> { - self.0.first().map(|item| &**item) + self.0.first().map(|(_, item)| &**item) } /// Access the last item. pub fn last(&self) -> Option<&Item<'a>> { - self.0.last().map(|item| &**item) + self.0.last().map(|(_, item)| &**item) } /// Access the first item mutably, if it is text. pub fn first_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { - self.0.first_mut()?.text_mut() + self.0.first_mut()?.1.text_mut() } /// Access the last item mutably, if it is text. pub fn last_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { - self.0.last_mut()?.text_mut() + self.0.last_mut()?.1.text_mut() } /// Reorder the items starting at the given index to RTL. @@ -673,12 +706,12 @@ impl<'a> Items<'a> { impl<'a> FromIterator> for Items<'a> { fn from_iter>>(iter: I) -> Self { - Self(iter.into_iter().collect()) + Self(iter.into_iter().enumerate().collect()) } } impl<'a> Deref for Items<'a> { - type Target = Vec>; + type Target = Vec<(usize, ItemEntry<'a>)>; fn deref(&self) -> &Self::Target { &self.0 @@ -698,6 +731,10 @@ impl Debug for Items<'_> { } /// A reference to or a boxed item. +/// +/// This is conceptually similar to a [`Cow<'a, Item<'a>>`][std::borrow::Cow], +/// but we box owned items since an [`Item`] is much bigger than +/// a box. pub enum ItemEntry<'a> { Ref(&'a Item<'a>), Box(Box>), diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index ada048c7d..955360df1 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -846,7 +846,9 @@ fn hyphenate_at(p: &Preparation, offset: usize) -> bool { p.config.hyphenate.unwrap_or_else(|| { let (_, item) = p.get(offset); match item.text() { - Some(text) => TextElem::hyphenate_in(text.styles).unwrap_or(p.config.justify), + Some(text) => { + text.styles.get(TextElem::hyphenate).unwrap_or(p.config.justify) + } None => false, } }) @@ -857,7 +859,7 @@ fn lang_at(p: &Preparation, offset: usize) -> Option { let lang = p.config.lang.or_else(|| { let (_, item) = p.get(offset); let styles = item.text()?.styles; - Some(TextElem::lang_in(styles)) + Some(styles.get(TextElem::lang)) })?; let bytes = lang.as_str().as_bytes().try_into().ok()?; @@ -927,9 +929,9 @@ impl Estimates { let byte_len = g.range.len(); let stretch = g.stretchability().0 + g.stretchability().1; let shrink = g.shrinkability().0 + g.shrinkability().1; - widths.push(byte_len, g.x_advance.at(shaped.size)); - stretchability.push(byte_len, stretch.at(shaped.size)); - shrinkability.push(byte_len, shrink.at(shaped.size)); + widths.push(byte_len, g.x_advance.at(g.size)); + stretchability.push(byte_len, stretch.at(g.size)); + shrinkability.push(byte_len, shrink.at(g.size)); justifiables.push(byte_len, g.is_justifiable() as usize); } } else { diff --git a/crates/typst-layout/src/inline/mod.rs b/crates/typst-layout/src/inline/mod.rs index 6cafb9b00..06223cebf 100644 --- a/crates/typst-layout/src/inline/mod.rs +++ b/crates/typst-layout/src/inline/mod.rs @@ -14,7 +14,7 @@ pub use self::shaping::create_shape_plan; use comemo::{Track, Tracked, TrackedMut}; use typst_library::diag::SourceResult; use typst_library::engine::{Engine, Route, Sink, Traced}; -use typst_library::foundations::{Packed, Resolve, Smart, StyleChain}; +use typst_library::foundations::{Packed, Smart, StyleChain}; use typst_library::introspection::{Introspector, Locator, LocatorLink, SplitLocator}; use typst_library::layout::{Abs, AlignElem, Dir, FixedAlignment, Fragment, Size}; use typst_library::model::{ @@ -29,7 +29,7 @@ use typst_utils::{Numeric, SliceExt}; use self::collect::{collect, Item, Segment, SpanMapper}; use self::deco::decorate; use self::finalize::finalize; -use self::line::{apply_baseline_shift, commit, line, Line}; +use self::line::{apply_shift, commit, line, Line}; use self::linebreak::{linebreak, Breakpoint}; use self::prepare::{prepare, Preparation}; use self::shaping::{ @@ -113,10 +113,10 @@ fn layout_par_impl( expand, Some(situation), &ConfigBase { - justify: elem.justify(styles), - linebreaks: elem.linebreaks(styles), - first_line_indent: elem.first_line_indent(styles), - hanging_indent: elem.hanging_indent(styles), + justify: elem.justify.get(styles), + linebreaks: elem.linebreaks.get(styles), + first_line_indent: elem.first_line_indent.get(styles), + hanging_indent: elem.hanging_indent.resolve(styles), }, ) } @@ -139,10 +139,10 @@ pub fn layout_inline<'a>( expand, None, &ConfigBase { - justify: ParElem::justify_in(shared), - linebreaks: ParElem::linebreaks_in(shared), - first_line_indent: ParElem::first_line_indent_in(shared), - hanging_indent: ParElem::hanging_indent_in(shared), + justify: shared.get(ParElem::justify), + linebreaks: shared.get(ParElem::linebreaks), + first_line_indent: shared.get(ParElem::first_line_indent), + hanging_indent: shared.resolve(ParElem::hanging_indent), }, ) } @@ -184,8 +184,8 @@ fn configuration( situation: Option, ) -> Config { let justify = base.justify; - let font_size = TextElem::size_in(shared); - let dir = TextElem::dir_in(shared); + let font_size = shared.resolve(TextElem::size); + let dir = shared.resolve(TextElem::dir); Config { justify, @@ -207,7 +207,7 @@ fn configuration( Some(ParSituation::Other) => all, None => false, } - && AlignElem::alignment_in(shared).resolve(shared).x == dir.start().into() + && shared.resolve(AlignElem::alignment).x == dir.start().into() { amount.at(font_size) } else { @@ -219,26 +219,26 @@ fn configuration( } else { Abs::zero() }, - numbering_marker: ParLine::numbering_in(shared).map(|numbering| { + numbering_marker: shared.get_cloned(ParLine::numbering).map(|numbering| { Packed::new(ParLineMarker::new( numbering, - ParLine::number_align_in(shared), - ParLine::number_margin_in(shared), + shared.get(ParLine::number_align), + shared.get(ParLine::number_margin), // Delay resolving the number clearance until line numbers are // laid out to avoid inconsistent spacing depending on varying // font size. - ParLine::number_clearance_in(shared), + shared.get(ParLine::number_clearance), )) }), - align: AlignElem::alignment_in(shared).fix(dir).x, + align: shared.get(AlignElem::alignment).fix(dir).x, font_size, dir, - hyphenate: shared_get(children, shared, TextElem::hyphenate_in) + hyphenate: shared_get(children, shared, |s| s.get(TextElem::hyphenate)) .map(|uniform| uniform.unwrap_or(justify)), - lang: shared_get(children, shared, TextElem::lang_in), - fallback: TextElem::fallback_in(shared), - cjk_latin_spacing: TextElem::cjk_latin_spacing_in(shared).is_auto(), - costs: TextElem::costs_in(shared), + lang: shared_get(children, shared, |s| s.get(TextElem::lang)), + fallback: shared.get(TextElem::fallback), + cjk_latin_spacing: shared.get(TextElem::cjk_latin_spacing).is_auto(), + costs: shared.get(TextElem::costs), } } @@ -314,7 +314,7 @@ fn shared_get( /// When we support some kind of more general ancestry mechanism, this can /// become more elegant. fn in_list(styles: StyleChain) -> bool { - ListElem::depth_in(styles).0 > 0 - || !EnumElem::parents_in(styles).is_empty() - || TermsElem::within_in(styles) + styles.get(ListElem::depth).0 > 0 + || !styles.get_cloned(EnumElem::parents).is_empty() + || styles.get(TermsElem::within) } diff --git a/crates/typst-layout/src/inline/prepare.rs b/crates/typst-layout/src/inline/prepare.rs index 5d7fcd7cb..ab39bdb14 100644 --- a/crates/typst-layout/src/inline/prepare.rs +++ b/crates/typst-layout/src/inline/prepare.rs @@ -144,7 +144,7 @@ fn add_cjk_latin_spacing(items: &mut [(Range, Item)]) { // The spacing is default to 1/4 em, and can be shrunk to 1/8 em. glyph.x_advance += Em::new(0.25); glyph.adjustability.shrinkability.1 += Em::new(0.125); - text.width += Em::new(0.25).at(text.size); + text.width += Em::new(0.25).at(glyph.size); } // Case 2: Latin followed by a CJ character @@ -152,7 +152,7 @@ fn add_cjk_latin_spacing(items: &mut [(Range, Item)]) { glyph.x_advance += Em::new(0.25); glyph.x_offset += Em::new(0.25); glyph.adjustability.shrinkability.0 += Em::new(0.125); - text.width += Em::new(0.25).at(text.size); + text.width += Em::new(0.25).at(glyph.size); } prev = Some(glyph); diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index 935a86b38..d1e748da8 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -3,14 +3,15 @@ use std::fmt::{self, Debug, Formatter}; use std::sync::Arc; use az::SaturatingAs; -use rustybuzz::{BufferFlags, ShapePlan, UnicodeBuffer}; +use rustybuzz::{BufferFlags, Feature, ShapePlan, UnicodeBuffer}; +use ttf_parser::gsub::SubstitutionSubtable; use ttf_parser::Tag; use typst_library::engine::Engine; use typst_library::foundations::{Smart, StyleChain}; use typst_library::layout::{Abs, Dir, Em, Frame, FrameItem, Point, Size}; use typst_library::text::{ families, features, is_default_ignorable, language, variant, Font, FontFamily, - FontVariant, Glyph, Lang, Region, TextEdgeBounds, TextElem, TextItem, + FontVariant, Glyph, Lang, Region, ShiftSettings, TextEdgeBounds, TextElem, TextItem, }; use typst_library::World; use typst_utils::SliceExt; @@ -41,8 +42,6 @@ pub struct ShapedText<'a> { pub styles: StyleChain<'a>, /// The font variant. pub variant: FontVariant, - /// The font size. - pub size: Abs, /// The width of the text's bounding box. pub width: Abs, /// The shaped glyphs. @@ -62,6 +61,8 @@ pub struct ShapedGlyph { pub x_offset: Em, /// The vertical offset of the glyph. pub y_offset: Em, + /// The font size for the glyph. + pub size: Abs, /// The adjustability of the glyph. pub adjustability: Adjustability, /// The byte range of this glyph's cluster in the full inline layout. A @@ -222,14 +223,17 @@ impl<'a> ShapedText<'a> { let mut frame = Frame::soft(size); frame.set_baseline(top); - let shift = TextElem::baseline_in(self.styles); - let decos = TextElem::deco_in(self.styles); - let fill = TextElem::fill_in(self.styles); - let stroke = TextElem::stroke_in(self.styles); - let span_offset = TextElem::span_offset_in(self.styles); + let size = self.styles.resolve(TextElem::size); + let shift = self.styles.resolve(TextElem::baseline); + let decos = self.styles.get_cloned(TextElem::deco); + let fill = self.styles.get_ref(TextElem::fill); + let stroke = self.styles.resolve(TextElem::stroke); + let span_offset = self.styles.get(TextElem::span_offset); - for ((font, y_offset), group) in - self.glyphs.as_ref().group_by_key(|g| (g.font.clone(), g.y_offset)) + for ((font, y_offset, glyph_size), group) in self + .glyphs + .as_ref() + .group_by_key(|g| (g.font.clone(), g.y_offset, g.size)) { let mut range = group[0].range.clone(); for glyph in group { @@ -237,7 +241,7 @@ impl<'a> ShapedText<'a> { range.end = range.end.max(glyph.range.end); } - let pos = Point::new(offset, top + shift - y_offset.at(self.size)); + let pos = Point::new(offset, top + shift - y_offset.at(size)); let glyphs: Vec = group .iter() .map(|shaped: &ShapedGlyph| { @@ -257,11 +261,11 @@ impl<'a> ShapedText<'a> { adjustability_right * justification_ratio; if shaped.is_justifiable() { justification_right += - Em::from_length(extra_justification, self.size) + Em::from_abs(extra_justification, glyph_size) } - frame.size_mut().x += justification_left.at(self.size) - + justification_right.at(self.size); + frame.size_mut().x += justification_left.at(glyph_size) + + justification_right.at(glyph_size); // We may not be able to reach the offset completely if // it exceeds u16, but better to have a roughly correct @@ -304,7 +308,7 @@ impl<'a> ShapedText<'a> { let item = TextItem { font, - size: self.size, + size: glyph_size, lang: self.lang, region: self.region, fill: fill.clone(), @@ -336,12 +340,13 @@ impl<'a> ShapedText<'a> { let mut top = Abs::zero(); let mut bottom = Abs::zero(); - let top_edge = TextElem::top_edge_in(self.styles); - let bottom_edge = TextElem::bottom_edge_in(self.styles); + let size = self.styles.resolve(TextElem::size); + let top_edge = self.styles.get(TextElem::top_edge); + let bottom_edge = self.styles.get(TextElem::bottom_edge); // Expand top and bottom by reading the font's vertical metrics. let mut expand = |font: &Font, bounds: TextEdgeBounds| { - let (t, b) = font.edges(top_edge, bottom_edge, self.size, bounds); + let (t, b) = font.edges(top_edge, bottom_edge, size, bounds); top.set_max(t); bottom.set_max(b); }; @@ -388,18 +393,16 @@ impl<'a> ShapedText<'a> { pub fn stretchability(&self) -> Abs { self.glyphs .iter() - .map(|g| g.stretchability().0 + g.stretchability().1) - .sum::() - .at(self.size) + .map(|g| (g.stretchability().0 + g.stretchability().1).at(g.size)) + .sum() } /// The shrinkability of the text pub fn shrinkability(&self) -> Abs { self.glyphs .iter() - .map(|g| g.shrinkability().0 + g.shrinkability().1) - .sum::() - .at(self.size) + .map(|g| (g.shrinkability().0 + g.shrinkability().1).at(g.size)) + .sum() } /// Reshape a range of the shaped text, reusing information from this @@ -418,9 +421,8 @@ impl<'a> ShapedText<'a> { lang: self.lang, region: self.region, styles: self.styles, - size: self.size, variant: self.variant, - width: glyphs.iter().map(|g| g.x_advance).sum::().at(self.size), + width: glyphs_width(glyphs), glyphs: Cow::Borrowed(glyphs), } } else { @@ -484,13 +486,15 @@ impl<'a> ShapedText<'a> { // that subtracting either of the endpoints by self.base doesn't // underflow. See . .unwrap_or_else(|| self.base..self.base); - self.width += x_advance.at(self.size); + let size = self.styles.resolve(TextElem::size); + self.width += x_advance.at(size); let glyph = ShapedGlyph { font, glyph_id: glyph_id.0, x_advance, x_offset: Em::zero(), y_offset: Em::zero(), + size, adjustability: Adjustability::default(), range, safe_to_break: true, @@ -599,9 +603,9 @@ pub fn shape_range<'a>( range: Range, styles: StyleChain<'a>, ) { - let script = TextElem::script_in(styles); - let lang = TextElem::lang_in(styles); - let region = TextElem::region_in(styles); + let script = styles.get(TextElem::script); + let lang = styles.get(TextElem::lang); + let region = styles.get(TextElem::region); let mut process = |range: Range, level: BidiLevel| { let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL }; let shaped = @@ -665,7 +669,8 @@ fn shape<'a>( lang: Lang, region: Option, ) -> ShapedText<'a> { - let size = TextElem::size_in(styles); + let size = styles.resolve(TextElem::size); + let shift_settings = styles.get(TextElem::shift_settings); let mut ctx = ShapingContext { engine, size, @@ -674,8 +679,9 @@ fn shape<'a>( styles, variant: variant(styles), features: features(styles), - fallback: TextElem::fallback_in(styles), + fallback: styles.get(TextElem::fallback), dir, + shift_settings, }; if !text.is_empty() { @@ -698,12 +704,17 @@ fn shape<'a>( region, styles, variant: ctx.variant, - size, - width: ctx.glyphs.iter().map(|g| g.x_advance).sum::().at(size), + width: glyphs_width(&ctx.glyphs), glyphs: Cow::Owned(ctx.glyphs), } } +/// Computes the width of a run of glyphs relative to the font size, accounting +/// for their individual scaling factors and other font metrics. +fn glyphs_width(glyphs: &[ShapedGlyph]) -> Abs { + glyphs.iter().map(|g| g.x_advance.at(g.size)).sum() +} + /// Holds shaping results and metadata common to all shaped segments. struct ShapingContext<'a, 'v> { engine: &'a Engine<'v>, @@ -715,6 +726,7 @@ struct ShapingContext<'a, 'v> { features: Vec, fallback: bool, dir: Dir, + shift_settings: Option, } /// Shape text with font fallback using the `families` iterator. @@ -771,7 +783,7 @@ fn shape_segment<'a>( let mut buffer = UnicodeBuffer::new(); buffer.push_str(text); buffer.set_language(language(ctx.styles)); - if let Some(script) = TextElem::script_in(ctx.styles).custom().and_then(|script| { + if let Some(script) = ctx.styles.get(TextElem::script).custom().and_then(|script| { rustybuzz::Script::from_iso15924_tag(Tag::from_bytes(script.as_bytes())) }) { buffer.set_script(script) @@ -789,6 +801,18 @@ fn shape_segment<'a>( // text extraction. buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES); + let (script_shift, script_compensation, scale, shift_feature) = ctx + .shift_settings + .map_or((Em::zero(), Em::zero(), Em::one(), None), |settings| { + determine_shift(text, &font, settings) + }); + + let has_shift_feature = shift_feature.is_some(); + if let Some(feat) = shift_feature { + // Temporarily push the feature. + ctx.features.push(feat) + } + // Prepare the shape plan. This plan depends on direction, script, language, // and features, but is independent from the text and can thus be memoized. let plan = create_shape_plan( @@ -799,6 +823,10 @@ fn shape_segment<'a>( &ctx.features, ); + if has_shift_feature { + ctx.features.pop(); + } + // Shape! let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer); let infos = buffer.glyph_infos(); @@ -869,8 +897,9 @@ fn shape_segment<'a>( glyph_id: info.glyph_id as u16, // TODO: Don't ignore y_advance. x_advance, - x_offset: font.to_em(pos[i].x_offset), - y_offset: font.to_em(pos[i].y_offset), + x_offset: font.to_em(pos[i].x_offset) + script_compensation, + y_offset: font.to_em(pos[i].y_offset) + script_shift, + size: scale.at(ctx.size), adjustability: Adjustability::default(), range: start..end, safe_to_break: !info.unsafe_to_break(), @@ -932,6 +961,64 @@ fn shape_segment<'a>( ctx.used.pop(); } +/// Returns a `(script_shift, script_compensation, scale, feature)` quadruplet +/// describing how to produce scripts. +/// +/// Those values determine how the rendered text should be transformed to +/// display sub-/super-scripts. If the OpenType feature can be used, the +/// rendered text should not be transformed in any way, and so those values are +/// neutral (`(0, 0, 1, None)`). If scripts should be synthesized, those values +/// determine how to transform the rendered text to display scripts as expected. +fn determine_shift( + text: &str, + font: &Font, + settings: ShiftSettings, +) -> (Em, Em, Em, Option) { + settings + .typographic + .then(|| { + // If typographic scripts are enabled (i.e., we want to use the + // OpenType feature instead of synthesizing if possible), we add + // "subs"/"sups" to the feature list if supported by the font. + // In case of a problem, we just early exit + let gsub = font.rusty().tables().gsub?; + let subtable_index = + gsub.features.find(settings.kind.feature())?.lookup_indices.get(0)?; + let coverage = gsub + .lookups + .get(subtable_index)? + .subtables + .get::(0)? + .coverage(); + text.chars() + .all(|c| { + font.rusty().glyph_index(c).is_some_and(|i| coverage.contains(i)) + }) + .then(|| { + // If we can use the OpenType feature, we can keep the text + // as is. + ( + Em::zero(), + Em::zero(), + Em::one(), + Some(Feature::new(settings.kind.feature(), 1, ..)), + ) + }) + }) + // Reunite the cases where `typographic` is `false` or where using the + // OpenType feature would not work. + .flatten() + .unwrap_or_else(|| { + let script_metrics = settings.kind.read_metrics(font.metrics()); + ( + settings.shift.unwrap_or(script_metrics.vertical_offset), + script_metrics.horizontal_offset, + settings.size.unwrap_or(script_metrics.height), + None, + ) + }) +} + /// Create a shape plan. #[comemo::memoize] pub fn create_shape_plan( @@ -963,6 +1050,7 @@ fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) { x_advance, x_offset: Em::zero(), y_offset: Em::zero(), + size: ctx.size, adjustability: Adjustability::default(), range: start..end, safe_to_break: true, @@ -985,9 +1073,11 @@ fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) { /// Apply tracking and spacing to the shaped glyphs. fn track_and_space(ctx: &mut ShapingContext) { - let tracking = Em::from_length(TextElem::tracking_in(ctx.styles), ctx.size); - let spacing = - TextElem::spacing_in(ctx.styles).map(|abs| Em::from_length(abs, ctx.size)); + let tracking = Em::from_abs(ctx.styles.resolve(TextElem::tracking), ctx.size); + let spacing = ctx + .styles + .resolve(TextElem::spacing) + .map(|abs| Em::from_abs(abs, ctx.size)); let mut glyphs = ctx.glyphs.iter_mut().peekable(); while let Some(glyph) = glyphs.next() { diff --git a/crates/typst-layout/src/lib.rs b/crates/typst-layout/src/lib.rs index 443e90d61..361bab463 100644 --- a/crates/typst-layout/src/lib.rs +++ b/crates/typst-layout/src/lib.rs @@ -10,21 +10,11 @@ mod modifiers; mod pad; mod pages; mod repeat; +mod rules; mod shapes; mod stack; mod transforms; -pub use self::flow::{layout_columns, layout_fragment, layout_frame}; -pub use self::grid::{layout_grid, layout_table}; -pub use self::image::layout_image; -pub use self::lists::{layout_enum, layout_list}; -pub use self::math::{layout_equation_block, layout_equation_inline}; -pub use self::pad::layout_pad; +pub use self::flow::{layout_fragment, layout_frame}; pub use self::pages::layout_document; -pub use self::repeat::layout_repeat; -pub use self::shapes::{ - layout_circle, layout_curve, layout_ellipse, layout_line, layout_path, - layout_polygon, layout_rect, layout_square, -}; -pub use self::stack::layout_stack; -pub use self::transforms::{layout_move, layout_rotate, layout_scale, layout_skew}; +pub use self::rules::register; diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index 974788a70..adb793fb9 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -20,20 +20,21 @@ pub fn layout_list( styles: StyleChain, regions: Regions, ) -> SourceResult { - let indent = elem.indent(styles); - let body_indent = elem.body_indent(styles); - let tight = elem.tight(styles); - let gutter = elem.spacing(styles).unwrap_or_else(|| { + let indent = elem.indent.get(styles); + let body_indent = elem.body_indent.get(styles); + let tight = elem.tight.get(styles); + let gutter = elem.spacing.get(styles).unwrap_or_else(|| { if tight { - ParElem::leading_in(styles).into() + styles.get(ParElem::leading) } else { - ParElem::spacing_in(styles).into() + styles.get(ParElem::spacing) } }); - let Depth(depth) = ListElem::depth_in(styles); + let Depth(depth) = styles.get(ListElem::depth); let marker = elem - .marker(styles) + .marker + .get_ref(styles) .resolve(engine, styles, depth)? // avoid '#set align' interference with the list .aligned(HAlignment::Start + VAlignment::Top); @@ -52,7 +53,7 @@ pub fn layout_list( cells.push(Cell::new(marker.clone(), locator.next(&marker.span()))); cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new( - body.styled(ListElem::set_depth(Depth(1))), + body.set(ListElem::depth, Depth(1)), locator.next(&item.body.span()), )); } @@ -81,40 +82,40 @@ pub fn layout_enum( styles: StyleChain, regions: Regions, ) -> SourceResult { - let numbering = elem.numbering(styles); - let reversed = elem.reversed(styles); - let indent = elem.indent(styles); - let body_indent = elem.body_indent(styles); - let tight = elem.tight(styles); - let gutter = elem.spacing(styles).unwrap_or_else(|| { + let numbering = elem.numbering.get_ref(styles); + let reversed = elem.reversed.get(styles); + let indent = elem.indent.get(styles); + let body_indent = elem.body_indent.get(styles); + let tight = elem.tight.get(styles); + let gutter = elem.spacing.get(styles).unwrap_or_else(|| { if tight { - ParElem::leading_in(styles).into() + styles.get(ParElem::leading) } else { - ParElem::spacing_in(styles).into() + styles.get(ParElem::spacing) } }); let mut cells = vec![]; let mut locator = locator.split(); - let mut number = elem.start(styles).unwrap_or_else(|| { + let mut number = elem.start.get(styles).unwrap_or_else(|| { if reversed { elem.children.len() as u64 } else { 1 } }); - let mut parents = EnumElem::parents_in(styles); + let mut parents = styles.get_cloned(EnumElem::parents); - let full = elem.full(styles); + let full = elem.full.get(styles); // Horizontally align based on the given respective parameter. // Vertically align to the top to avoid inheriting `horizon` or `bottom` // alignment from the context and having the number be displaced in // relation to the item it refers to. - let number_align = elem.number_align(styles); + let number_align = elem.number_align.get(styles); for item in &elem.children { - number = item.number(styles).unwrap_or(number); + number = item.number.get(styles).unwrap_or(number); let context = Context::new(None, Some(styles)); let resolved = if full { @@ -133,8 +134,7 @@ pub fn layout_enum( // Disable overhang as a workaround to end-aligned dots glitching // and decreasing spacing between numbers and items. - let resolved = - resolved.aligned(number_align).styled(TextElem::set_overhang(false)); + let resolved = resolved.aligned(number_align).set(TextElem::overhang, false); // Text in wide enums shall always turn into paragraphs. let mut body = item.body.clone(); @@ -146,7 +146,7 @@ pub fn layout_enum( cells.push(Cell::new(resolved, locator.next(&()))); cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new( - body.styled(EnumElem::set_parents(smallvec![number])), + body.set(EnumElem::parents, smallvec![number]), locator.next(&item.body.span()), )); number = diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index 159703b8e..e7f051ace 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -24,7 +24,7 @@ pub fn layout_accent( // Try to replace the base glyph with its dotless variant. let dtls = style_dtls(); let base_styles = - if top_accent && elem.dotless(styles) { styles.chain(&dtls) } else { styles }; + if top_accent && elem.dotless.get(styles) { styles.chain(&dtls) } else { styles }; let cramped = style_cramped(); let base = ctx.layout_into_fragment(&elem.base, base_styles.chain(&cramped))?; @@ -47,7 +47,7 @@ pub fn layout_accent( // Forcing the accent to be at least as large as the base makes it too wide // in many cases. - let width = elem.size(styles).relative_to(base.width()); + let width = elem.size.resolve(styles).relative_to(base.width()); let short_fall = ACCENT_SHORT_FALL.at(glyph.item.size); glyph.stretch_horizontal(ctx, width - short_fall); let accent_attach = glyph.accent_attach.0; diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs index a7f3cad5f..78b6f5515 100644 --- a/crates/typst-layout/src/math/attach.rs +++ b/crates/typst-layout/src/math/attach.rs @@ -31,16 +31,16 @@ pub fn layout_attach( let mut base = ctx.layout_into_fragment(&elem.base, styles)?; let sup_style = style_for_superscript(styles); let sup_style_chain = styles.chain(&sup_style); - let tl = elem.tl(sup_style_chain); - let tr = elem.tr(sup_style_chain); + let tl = elem.tl.get_cloned(sup_style_chain); + let tr = elem.tr.get_cloned(sup_style_chain); let primed = tr.as_ref().is_some_and(|content| content.is::()); - let t = elem.t(sup_style_chain); + let t = elem.t.get_cloned(sup_style_chain); let sub_style = style_for_subscript(styles); let sub_style_chain = styles.chain(&sub_style); - let bl = elem.bl(sub_style_chain); - let br = elem.br(sub_style_chain); - let b = elem.b(sub_style_chain); + let bl = elem.bl.get_cloned(sub_style_chain); + let br = elem.br.get_cloned(sub_style_chain); + let b = elem.b.get_cloned(sub_style_chain); let limits = base.limits().active(styles); let (t, tr) = match (t, tr) { @@ -146,7 +146,7 @@ pub fn layout_limits( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let limits = if elem.inline(styles) { Limits::Always } else { Limits::Display }; + let limits = if elem.inline.get(styles) { Limits::Always } else { Limits::Display }; let mut fragment = ctx.layout_into_fragment(&elem.body, styles)?; fragment.set_limits(limits); ctx.push(fragment); @@ -161,7 +161,8 @@ fn stretch_size(styles: StyleChain, elem: &Packed) -> Option().map(|stretch| stretch.size(styles)) + base.to_packed::() + .map(|stretch| stretch.size.resolve(styles)) } /// Lay out the attachments. @@ -397,7 +398,7 @@ fn compute_script_shifts( base: &MathFragment, [tl, tr, bl, br]: [&Option; 4], ) -> (Abs, Abs) { - let sup_shift_up = if EquationElem::cramped_in(styles) { + let sup_shift_up = if styles.get(EquationElem::cramped) { scaled!(ctx, styles, superscript_shift_up_cramped) } else { scaled!(ctx, styles, superscript_shift_up) diff --git a/crates/typst-layout/src/math/cancel.rs b/crates/typst-layout/src/math/cancel.rs index 9826397fa..57a32ca2a 100644 --- a/crates/typst-layout/src/math/cancel.rs +++ b/crates/typst-layout/src/math/cancel.rs @@ -27,16 +27,16 @@ pub fn layout_cancel( let mut body = body.into_frame(); let body_size = body.size(); let span = elem.span(); - let length = elem.length(styles); + let length = elem.length.resolve(styles); - let stroke = elem.stroke(styles).unwrap_or(FixedStroke { - paint: TextElem::fill_in(styles).as_decoration(), + let stroke = elem.stroke.resolve(styles).unwrap_or(FixedStroke { + paint: styles.get_ref(TextElem::fill).as_decoration(), ..Default::default() }); - let invert = elem.inverted(styles); - let cross = elem.cross(styles); - let angle = elem.angle(styles); + let invert = elem.inverted.get(styles); + let cross = elem.cross.get(styles); + let angle = elem.angle.get_ref(styles); let invert_first_line = !cross && invert; let first_line = draw_cancel_line( @@ -44,7 +44,7 @@ pub fn layout_cancel( length, stroke.clone(), invert_first_line, - &angle, + angle, body_size, styles, span, @@ -57,7 +57,7 @@ pub fn layout_cancel( if cross { // Draw the second line. let second_line = - draw_cancel_line(ctx, length, stroke, true, &angle, body_size, styles, span)?; + draw_cancel_line(ctx, length, stroke, true, angle, body_size, styles, span)?; body.push_frame(center, second_line); } diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs index 091f328f6..12a2c6fd1 100644 --- a/crates/typst-layout/src/math/frac.rs +++ b/crates/typst-layout/src/math/frac.rs @@ -124,7 +124,7 @@ fn layout_frac_like( FrameItem::Shape( Geometry::Line(Point::with_x(line_width)).stroked( FixedStroke::from_pair( - TextElem::fill_in(styles).as_decoration(), + styles.get_ref(TextElem::fill).as_decoration(), thickness, ), ), diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index eb85eeb5d..758dd401f 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -215,7 +215,7 @@ impl MathFragment { &glyph.item.font, GlyphId(glyph.item.glyphs[glyph_index].id), corner, - Em::from_length(height, glyph.item.size), + Em::from_abs(height, glyph.item.size), ) .unwrap_or_default() .at(glyph.item.size) @@ -315,7 +315,8 @@ impl GlyphFragment { let cluster = info.cluster as usize; let c = text[cluster..].chars().next().unwrap(); let limits = Limits::for_char(c); - let class = EquationElem::class_in(styles) + let class = styles + .get(EquationElem::class) .or_else(|| default_math_class(c)) .unwrap_or(MathClass::Normal); @@ -331,11 +332,11 @@ impl GlyphFragment { let item = TextItem { font: font.clone(), - size: TextElem::size_in(styles), - fill: TextElem::fill_in(styles).as_decoration(), - stroke: TextElem::stroke_in(styles).map(|s| s.unwrap_or_default()), - lang: TextElem::lang_in(styles), - region: TextElem::region_in(styles), + size: styles.resolve(TextElem::size), + fill: styles.get_ref(TextElem::fill).as_decoration(), + stroke: styles.resolve(TextElem::stroke).map(|s| s.unwrap_or_default()), + lang: styles.get(TextElem::lang), + region: styles.get(TextElem::region), text: text.into(), glyphs: vec![glyph.clone()], }; @@ -344,7 +345,7 @@ impl GlyphFragment { item, base_glyph: glyph, // Math - math_size: EquationElem::size_in(styles), + math_size: styles.get(EquationElem::size), class, limits, mid_stretched: None, @@ -356,7 +357,7 @@ impl GlyphFragment { baseline: None, // Misc align: Abs::zero(), - shift: TextElem::baseline_in(styles), + shift: styles.resolve(TextElem::baseline), modifiers: FrameModifiers::get_in(styles), }; fragment.update_glyph(); @@ -541,9 +542,9 @@ impl FrameFragment { let accent_attach = frame.width() / 2.0; Self { frame: frame.modified(&FrameModifiers::get_in(styles)), - font_size: TextElem::size_in(styles), - class: EquationElem::class_in(styles).unwrap_or(MathClass::Normal), - math_size: EquationElem::size_in(styles), + font_size: styles.resolve(TextElem::size), + class: styles.get(EquationElem::class).unwrap_or(MathClass::Normal), + math_size: styles.get(EquationElem::size), limits: Limits::Never, spaced: false, base_ascent, @@ -767,8 +768,8 @@ fn assemble( advance += ratio * (max_overlap - min_overlap); } let (x, y) = match axis { - Axis::X => (Em::from_length(advance, base.item.size), Em::zero()), - Axis::Y => (Em::zero(), Em::from_length(advance, base.item.size)), + Axis::X => (Em::from_abs(advance, base.item.size), Em::zero()), + Axis::Y => (Em::zero(), Em::from_abs(advance, base.item.size)), }; glyphs.push(Glyph { id: part.glyph_id.0, @@ -864,7 +865,7 @@ impl Limits { pub fn active(&self, styles: StyleChain) -> bool { match self { Self::Always => true, - Self::Display => EquationElem::size_in(styles) == MathSize::Display, + Self::Display => styles.get(EquationElem::size) == MathSize::Display, Self::Never => false, } } diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs index a3b5cb05c..2348025e8 100644 --- a/crates/typst-layout/src/math/lr.rs +++ b/crates/typst-layout/src/math/lr.rs @@ -22,7 +22,7 @@ pub fn layout_lr( // Extract implicit LrElem. if let Some(lr) = body.to_packed::() { - if lr.size(styles).is_one() { + if lr.size.get(styles).is_one() { body = &lr.body; } } @@ -41,7 +41,7 @@ pub fn layout_lr( .unwrap_or_default(); let relative_to = 2.0 * max_extent; - let height = elem.size(styles); + let height = elem.size.resolve(styles); // Scale up fragments at both ends. match inner_fragments { diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index 278b1343e..4a897a03e 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -30,15 +30,15 @@ pub fn layout_vec( ctx, styles, &[column], - elem.align(styles), + elem.align.resolve(styles), LeftRightAlternator::Right, None, - Axes::with_y(elem.gap(styles)), + Axes::with_y(elem.gap.resolve(styles)), span, "elements", )?; - let delim = elem.delim(styles); + let delim = elem.delim.get(styles); layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) } @@ -59,14 +59,17 @@ pub fn layout_cases( FixedAlignment::Start, LeftRightAlternator::None, None, - Axes::with_y(elem.gap(styles)), + Axes::with_y(elem.gap.resolve(styles)), span, "branches", )?; - let delim = elem.delim(styles); - let (open, close) = - if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) }; + let delim = elem.delim.get(styles); + let (open, close) = if elem.reverse.get(styles) { + (None, delim.close()) + } else { + (delim.open(), None) + }; layout_delimiters(ctx, styles, frame, open, close, span) } @@ -81,7 +84,7 @@ pub fn layout_mat( let rows = &elem.rows; let ncols = rows.first().map_or(0, |row| row.len()); - let augment = elem.augment(styles); + let augment = elem.augment.resolve(styles); if let Some(aug) = &augment { for &offset in &aug.hline.0 { if offset == 0 || offset.unsigned_abs() >= rows.len() { @@ -116,15 +119,15 @@ pub fn layout_mat( ctx, styles, &columns, - elem.align(styles), + elem.align.resolve(styles), LeftRightAlternator::Right, augment, - Axes::new(elem.column_gap(styles), elem.row_gap(styles)), + Axes::new(elem.column_gap.resolve(styles), elem.row_gap.resolve(styles)), span, "cells", )?; - let delim = elem.delim(styles); + let delim = elem.delim.get(styles); layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) } @@ -157,7 +160,7 @@ fn layout_body( let default_stroke_thickness = DEFAULT_STROKE_THICKNESS.resolve(styles); let default_stroke = FixedStroke { thickness: default_stroke_thickness, - paint: TextElem::fill_in(styles).as_decoration(), + paint: styles.get_ref(TextElem::fill).as_decoration(), cap: LineCap::Square, ..Default::default() }; diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs index 5fd22e578..390835067 100644 --- a/crates/typst-layout/src/math/mod.rs +++ b/crates/typst-layout/src/math/mod.rs @@ -51,7 +51,7 @@ pub fn layout_equation_inline( styles: StyleChain, region: Size, ) -> SourceResult> { - assert!(!elem.block(styles)); + assert!(!elem.block.get(styles)); let font = find_math_font(engine, styles, elem.span())?; @@ -78,12 +78,12 @@ pub fn layout_equation_inline( for item in &mut items { let InlineItem::Frame(frame) = item else { continue }; - let slack = ParElem::leading_in(styles) * 0.7; + let slack = styles.resolve(ParElem::leading) * 0.7; let (t, b) = font.edges( - TextElem::top_edge_in(styles), - TextElem::bottom_edge_in(styles), - TextElem::size_in(styles), + styles.get(TextElem::top_edge), + styles.get(TextElem::bottom_edge), + styles.resolve(TextElem::size), TextEdgeBounds::Frame(frame), ); @@ -105,7 +105,7 @@ pub fn layout_equation_block( styles: StyleChain, regions: Regions, ) -> SourceResult { - assert!(elem.block(styles)); + assert!(elem.block.get(styles)); let span = elem.span(); let font = find_math_font(engine, styles, span)?; @@ -121,7 +121,7 @@ pub fn layout_equation_block( .multiline_frame_builder(styles); let width = full_equation_builder.size.x; - let equation_builders = if BlockElem::breakable_in(styles) { + let equation_builders = if styles.get(BlockElem::breakable) { let mut rows = full_equation_builder.frames.into_iter().peekable(); let mut equation_builders = vec![]; let mut last_first_pos = Point::zero(); @@ -188,7 +188,7 @@ pub fn layout_equation_block( vec![full_equation_builder] }; - let Some(numbering) = (**elem).numbering(styles) else { + let Some(numbering) = elem.numbering.get_ref(styles) else { let frames = equation_builders .into_iter() .map(MathRunFrameBuilder::build) @@ -197,7 +197,7 @@ pub fn layout_equation_block( }; let pod = Region::new(regions.base(), Axes::splat(false)); - let counter = Counter::of(EquationElem::elem()) + let counter = Counter::of(EquationElem::ELEM) .display_at_loc(engine, elem.location().unwrap(), styles, numbering)? .spanned(span); let number = crate::layout_frame(engine, &counter, locator.next(&()), styles, pod)?; @@ -205,7 +205,7 @@ pub fn layout_equation_block( static NUMBER_GUTTER: Em = Em::new(0.5); let full_number_width = number.width() + NUMBER_GUTTER.resolve(styles); - let number_align = match elem.number_align(styles) { + let number_align = match elem.number_align.get(styles) { SpecificAlignment::H(h) => SpecificAlignment::Both(h, VAlignment::Horizon), SpecificAlignment::V(v) => SpecificAlignment::Both(OuterHAlignment::End, v), SpecificAlignment::Both(h, v) => SpecificAlignment::Both(h, v), @@ -224,7 +224,7 @@ pub fn layout_equation_block( builder, number.clone(), number_align.resolve(styles), - AlignElem::alignment_in(styles).resolve(styles).x, + styles.get(AlignElem::alignment).resolve(styles).x, regions.size.x, full_number_width, ) @@ -472,7 +472,9 @@ impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> { let outer = styles; for (elem, styles) in pairs { // Hack because the font is fixed in math. - if styles != outer && TextElem::font_in(styles) != TextElem::font_in(outer) { + if styles != outer + && styles.get_ref(TextElem::font) != outer.get_ref(TextElem::font) + { let frame = layout_external(elem, self, styles)?; self.push(FrameFragment::new(styles, frame).with_spaced(true)); continue; @@ -603,7 +605,10 @@ fn layout_h( ) -> SourceResult<()> { if let Spacing::Rel(rel) = elem.amount { if rel.rel.is_zero() { - ctx.push(MathFragment::Spacing(rel.abs.resolve(styles), elem.weak(styles))); + ctx.push(MathFragment::Spacing( + rel.abs.resolve(styles), + elem.weak.get(styles), + )); } } Ok(()) @@ -616,7 +621,7 @@ fn layout_class( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let style = EquationElem::set_class(Some(elem.class)).wrap(); + let style = EquationElem::class.set(Some(elem.class)).wrap(); let mut fragment = ctx.layout_into_fragment(&elem.body, styles.chain(&style))?; fragment.set_class(elem.class); fragment.set_limits(Limits::for_class(elem.class)); @@ -642,7 +647,7 @@ fn layout_op( .with_italics_correction(italics) .with_accent_attach(accent_attach) .with_text_like(text_like) - .with_limits(if elem.limits(styles) { + .with_limits(if elem.limits.get(styles) { Limits::Display } else { Limits::Never diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs index 91b9b16af..30948e08e 100644 --- a/crates/typst-layout/src/math/root.rs +++ b/crates/typst-layout/src/math/root.rs @@ -17,7 +17,7 @@ pub fn layout_root( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let index = elem.index(styles); + let index = elem.index.get_ref(styles); let span = elem.span(); let gap = scaled!( @@ -54,7 +54,7 @@ pub fn layout_root( let sqrt = sqrt.into_frame(); // Layout the index. - let sscript = EquationElem::set_size(MathSize::ScriptScript).wrap(); + let sscript = EquationElem::size.set(MathSize::ScriptScript).wrap(); let index = index .as_ref() .map(|elem| ctx.layout_into_frame(elem, styles.chain(&sscript))) @@ -112,7 +112,7 @@ pub fn layout_root( FrameItem::Shape( Geometry::Line(Point::with_x(radicand.width())).stroked( FixedStroke::from_pair( - TextElem::fill_in(styles).as_decoration(), + styles.get_ref(TextElem::fill).as_decoration(), thickness, ), ), diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs index 4ec76c253..161fa1062 100644 --- a/crates/typst-layout/src/math/run.rs +++ b/crates/typst-layout/src/math/run.rs @@ -194,13 +194,13 @@ impl MathRun { let row_count = rows.len(); let alignments = alignments(&rows); - let leading = if EquationElem::size_in(styles) >= MathSize::Text { - ParElem::leading_in(styles) + let leading = if styles.get(EquationElem::size) >= MathSize::Text { + styles.resolve(ParElem::leading) } else { TIGHT_LEADING.resolve(styles) }; - let align = AlignElem::alignment_in(styles).resolve(styles).x; + let align = styles.resolve(AlignElem::alignment).x; let mut frames: Vec<(Frame, Point)> = vec![]; let mut size = Size::zero(); for (i, row) in rows.into_iter().enumerate() { diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs index 1f88d2dd7..c9d20aa68 100644 --- a/crates/typst-layout/src/math/shared.rs +++ b/crates/typst-layout/src/math/shared.rs @@ -10,7 +10,7 @@ use super::{LeftRightAlternator, MathContext, MathFragment, MathRun}; macro_rules! scaled { ($ctx:expr, $styles:expr, text: $text:ident, display: $display:ident $(,)?) => { - match typst_library::math::EquationElem::size_in($styles) { + match $styles.get(typst_library::math::EquationElem::size) { typst_library::math::MathSize::Display => scaled!($ctx, $styles, $display), _ => scaled!($ctx, $styles, $text), } @@ -19,7 +19,7 @@ macro_rules! scaled { $crate::math::Scaled::scaled( $ctx.constants.$name(), $ctx, - typst_library::text::TextElem::size_in($styles), + $styles.resolve(typst_library::text::TextElem::size), ) }; } @@ -58,55 +58,62 @@ impl Scaled for MathValue<'_> { /// Styles something as cramped. pub fn style_cramped() -> LazyHash + -
-
-

Output

- Placeholder -
- -
-

Reference

- Placeholder -
-
- ${stdoutHtml} - ${stderrHtml} + ${showRender ? renderSection(panel, name) : ""} + ${showHtml ? await htmlSection(name) : ""} + ${stdout} + ${stderr} `; } + +function renderSection(panel: vscode.WebviewPanel, name: string) { + const outputUri = getUri(name, "store", "render"); + const refUri = getUri(name, "ref", "render"); + return `
+
+ ${linkedTitle("Output", outputUri)} + Placeholder +
+ +
+ ${linkedTitle("Reference", refUri)} + Placeholder +
+
`; +} + +async function htmlSection(name: string) { + const storeHtml = await htmlSnippet( + "HTML Output", + getUri(name, "store", "html") + ); + const refHtml = await htmlSnippet( + "HTML Reference", + getUri(name, "ref", "html") + ); + return `
+ ${storeHtml} + ${refHtml} +
`; +} + +async function htmlSnippet(title: string, uri: vscode.Uri): Promise { + try { + const data = await vscode.workspace.fs.readFile(uri); + const code = new TextDecoder("utf-8").decode(data); + return `
+ ${linkedTitle(title, uri)} +
+ ${await highlight(code)} + +
+
`; + } catch { + return `

${title}

Not present
`; + } +} + +function linkedTitle(title: string, uri: vscode.Uri) { + return `

${title}

`; +} + +async function highlight(code: string): Promise { + return (await shiki).codeToHtml(code, { + lang: "html", + theme: selectTheme(), + }); +} + +function selectTheme() { + switch (vscode.window.activeColorTheme.kind) { + case vscode.ColorThemeKind.Light: + case vscode.ColorThemeKind.HighContrastLight: + return "github-light"; + case vscode.ColorThemeKind.Dark: + case vscode.ColorThemeKind.HighContrast: + return "github-dark"; + } +} + +function escape(text: string) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/tools/test-helper/tsconfig.json b/tools/test-helper/tsconfig.json index 45e374553..952c40426 100644 --- a/tools/test-helper/tsconfig.json +++ b/tools/test-helper/tsconfig.json @@ -1,9 +1,10 @@ { "compilerOptions": { - "module": "Node16", + "module": "nodenext", + "lib": ["ES2022", "DOM"], "target": "ES2022", + "moduleResolution": "nodenext", "outDir": "dist", - "lib": ["ES2022"], "sourceMap": true, "rootDir": "src", "strict": true