1use std::fmt::{self, Debug, Display, Formatter};
2
3use ecow::{EcoString, EcoVec};
4use typst_library::diag::{HintedStrResult, StrResult, bail};
5use typst_library::foundations::{Dict, Repr, Str, StyleChain, cast};
6use typst_library::introspection::{Introspector, Location, Tag};
7use typst_library::layout::{Abs, Frame, Point};
8use typst_library::model::DocumentInfo;
9use typst_library::text::TextElem;
10use typst_syntax::Span;
11use typst_utils::{PicoStr, ResolvedPicoStr};
12
13use crate::{attr, charsets, css};
14
15#[derive(Debug, Clone)]
17pub struct HtmlDocument {
18 pub root: HtmlElement,
20 pub info: DocumentInfo,
22 pub introspector: Introspector,
24}
25
26#[derive(Debug, Clone, Hash)]
28pub enum HtmlNode {
29 Tag(Tag),
31 Text(EcoString, Span),
33 Element(HtmlElement),
35 Frame(HtmlFrame),
37}
38
39impl HtmlNode {
40 pub fn text(text: impl Into<EcoString>, span: Span) -> Self {
42 Self::Text(text.into(), span)
43 }
44
45 pub fn span(&self) -> Span {
47 match self {
48 Self::Tag(_) => Span::detached(),
49 Self::Text(_, span) => *span,
50 Self::Element(element) => element.span,
51 Self::Frame(frame) => frame.span,
52 }
53 }
54}
55
56impl From<Tag> for HtmlNode {
57 fn from(tag: Tag) -> Self {
58 Self::Tag(tag)
59 }
60}
61
62impl From<HtmlElement> for HtmlNode {
63 fn from(element: HtmlElement) -> Self {
64 Self::Element(element)
65 }
66}
67
68impl From<HtmlFrame> for HtmlNode {
69 fn from(frame: HtmlFrame) -> Self {
70 Self::Frame(frame)
71 }
72}
73
74#[derive(Debug, Clone, Hash)]
76pub struct HtmlElement {
77 pub tag: HtmlTag,
79 pub attrs: HtmlAttrs,
81 pub children: EcoVec<HtmlNode>,
83 pub parent: Option<Location>,
86 pub span: Span,
88 pub pre_span: bool,
96}
97
98impl HtmlElement {
99 pub fn new(tag: HtmlTag) -> Self {
101 Self {
102 tag,
103 attrs: HtmlAttrs::default(),
104 children: EcoVec::new(),
105 parent: None,
106 span: Span::detached(),
107 pre_span: false,
108 }
109 }
110
111 pub fn with_children(mut self, children: EcoVec<HtmlNode>) -> Self {
115 self.children = children;
116 self
117 }
118
119 pub fn with_attr(mut self, key: HtmlAttr, value: impl Into<EcoString>) -> Self {
121 self.attrs.push(key, value);
122 self
123 }
124
125 pub(crate) fn with_styles(self, properties: css::Properties) -> Self {
127 if let Some(value) = properties.into_inline_styles() {
128 self.with_attr(attr::style, value)
129 } else {
130 self
131 }
132 }
133
134 pub fn spanned(mut self, span: Span) -> Self {
136 self.span = span;
137 self
138 }
139}
140
141#[derive(Copy, Clone, Eq, PartialEq, Hash)]
143pub struct HtmlTag(PicoStr);
144
145impl HtmlTag {
146 pub fn intern(string: &str) -> StrResult<Self> {
148 if string.is_empty() {
149 bail!("tag name must not be empty");
150 }
151
152 let mut has_hyphen = false;
153 let mut has_uppercase = false;
154
155 for c in string.chars() {
156 if c == '-' {
157 has_hyphen = true;
158 } else if !charsets::is_valid_in_tag_name(c) {
159 bail!("the character {} is not valid in a tag name", c.repr());
160 } else {
161 has_uppercase |= c.is_ascii_uppercase();
162 }
163 }
164
165 if has_hyphen {
177 if !string.starts_with(|c: char| c.is_ascii_lowercase()) {
178 bail!("custom element name must start with a lowercase letter");
179 }
180 if has_uppercase {
181 bail!("custom element name must not contain uppercase letters");
182 }
183
184 if matches!(
187 string,
188 "annotation-xml"
189 | "color-profile"
190 | "font-face"
191 | "font-face-src"
192 | "font-face-uri"
193 | "font-face-format"
194 | "font-face-name"
195 | "missing-glyph"
196 ) {
197 bail!("name is reserved and not valid for a custom element");
198 }
199 }
200
201 Ok(Self(PicoStr::intern(string)))
202 }
203
204 #[track_caller]
208 pub const fn constant(string: &'static str) -> Self {
209 if string.is_empty() {
210 panic!("tag name must not be empty");
211 }
212
213 let bytes = string.as_bytes();
214 let mut i = 0;
215 while i < bytes.len() {
216 if !bytes[i].is_ascii() || !charsets::is_valid_in_tag_name(bytes[i] as char) {
217 panic!("not all characters are valid in a tag name");
218 }
219 i += 1;
220 }
221
222 Self(PicoStr::constant(string))
223 }
224
225 pub fn resolve(self) -> ResolvedPicoStr {
227 self.0.resolve()
228 }
229
230 pub const fn into_inner(self) -> PicoStr {
232 self.0
233 }
234}
235
236impl Debug for HtmlTag {
237 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
238 Display::fmt(self, f)
239 }
240}
241
242impl Display for HtmlTag {
243 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
244 write!(f, "<{}>", self.resolve())
245 }
246}
247
248cast! {
249 HtmlTag,
250 self => self.0.resolve().as_str().into_value(),
251 v: Str => Self::intern(&v)?,
252}
253
254#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
256pub struct HtmlAttrs(pub EcoVec<(HtmlAttr, EcoString)>);
257
258impl HtmlAttrs {
259 pub fn new() -> Self {
261 Self::default()
262 }
263
264 pub fn push(&mut self, attr: HtmlAttr, value: impl Into<EcoString>) {
266 self.0.push((attr, value.into()));
267 }
268
269 pub fn push_front(&mut self, attr: HtmlAttr, value: impl Into<EcoString>) {
271 self.0.insert(0, (attr, value.into()));
272 }
273
274 pub fn get(&self, attr: HtmlAttr) -> Option<&EcoString> {
276 self.0.iter().find(|&&(k, _)| k == attr).map(|(_, v)| v)
277 }
278}
279
280cast! {
281 HtmlAttrs,
282 self => self.0
283 .into_iter()
284 .map(|(key, value)| (key.resolve().as_str().into(), value.into_value()))
285 .collect::<Dict>()
286 .into_value(),
287 values: Dict => Self(values
288 .into_iter()
289 .map(|(k, v)| {
290 let attr = HtmlAttr::intern(&k)?;
291 let value = v.cast::<EcoString>()?;
292 Ok((attr, value))
293 })
294 .collect::<HintedStrResult<_>>()?),
295}
296
297#[derive(Copy, Clone, Eq, PartialEq, Hash)]
299pub struct HtmlAttr(PicoStr);
300
301impl HtmlAttr {
302 pub fn intern(string: &str) -> StrResult<Self> {
304 if string.is_empty() {
305 bail!("attribute name must not be empty");
306 }
307
308 if let Some(c) =
309 string.chars().find(|&c| !charsets::is_valid_in_attribute_name(c))
310 {
311 bail!("the character {} is not valid in an attribute name", c.repr());
312 }
313
314 Ok(Self(PicoStr::intern(string)))
315 }
316
317 #[track_caller]
324 pub const fn constant(string: &'static str) -> Self {
325 if string.is_empty() {
326 panic!("attribute name must not be empty");
327 }
328
329 let bytes = string.as_bytes();
330 let mut i = 0;
331 while i < bytes.len() {
332 if !bytes[i].is_ascii()
333 || !charsets::is_valid_in_attribute_name(bytes[i] as char)
334 {
335 panic!("not all characters are valid in an attribute name");
336 }
337 i += 1;
338 }
339
340 Self(PicoStr::constant(string))
341 }
342
343 pub fn resolve(self) -> ResolvedPicoStr {
345 self.0.resolve()
346 }
347
348 pub const fn into_inner(self) -> PicoStr {
350 self.0
351 }
352}
353
354impl Debug for HtmlAttr {
355 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
356 Display::fmt(self, f)
357 }
358}
359
360impl Display for HtmlAttr {
361 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
362 write!(f, "{}", self.resolve())
363 }
364}
365
366cast! {
367 HtmlAttr,
368 self => self.0.resolve().as_str().into_value(),
369 v: Str => Self::intern(&v)?,
370}
371
372#[derive(Debug, Clone, Hash)]
374pub struct HtmlFrame {
375 pub inner: Frame,
377 pub text_size: Abs,
381 pub id: Option<EcoString>,
383 pub link_points: EcoVec<(Point, EcoString)>,
385 pub span: Span,
387}
388
389impl HtmlFrame {
390 pub fn new(inner: Frame, styles: StyleChain, span: Span) -> Self {
392 Self {
393 inner,
394 text_size: styles.resolve(TextElem::size),
395 id: None,
396 link_points: EcoVec::new(),
397 span,
398 }
399 }
400}