1use crate::model::{Block, BlockKind, Document, Page, TableData};
13use crate::plugin::{FormatPlugin, RawDoc};
14
15const SKIP_TAGS: [&str; 6] = ["script", "style", "noscript", "svg", "head", "title"];
16
17fn heading_level(tag: &str) -> Option<u8> {
18 match tag {
19 "h1" => Some(1),
20 "h2" => Some(2),
21 "h3" => Some(3),
22 "h4" => Some(4),
23 "h5" => Some(5),
24 "h6" => Some(6),
25 _ => None,
26 }
27}
28
29fn named_entity(name: &str) -> Option<char> {
30 Some(match name {
31 "amp" => '&',
32 "lt" => '<',
33 "gt" => '>',
34 "quot" => '"',
35 "apos" => '\'',
36 "nbsp" => '\u{a0}',
37 "mdash" => '—',
38 "ndash" => '–',
39 "hellip" => '…',
40 "copy" => '©',
41 "reg" => '®',
42 "times" => '×',
43 "middot" => '·',
44 "ldquo" => '“',
45 "rdquo" => '”',
46 "lsquo" => '‘',
47 "rsquo" => '’',
48 _ => return None,
49 })
50}
51
52fn numeric_entity(body: &str) -> Option<char> {
53 let rest = body.strip_prefix('#')?;
54 let code = match rest.strip_prefix(['x', 'X']) {
55 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
56 None => rest.parse::<u32>().ok()?,
57 };
58 char::from_u32(code)
59}
60
61fn decode_entities(s: &str) -> String {
63 if !s.contains('&') {
64 return s.to_string();
65 }
66 let mut out = String::with_capacity(s.len());
67 let mut iter = s.char_indices().peekable();
68 while let Some((i, c)) = iter.next() {
69 if c != '&' {
70 out.push(c);
71 continue;
72 }
73 let rest = &s[i..];
74 let end = rest.find(';').filter(|e| *e <= 10);
75 let decoded = end.and_then(|e| {
76 let body = &rest[1..e];
77 named_entity(body).or_else(|| numeric_entity(body))
78 });
79 match (end, decoded) {
80 (Some(e), Some(ch)) => {
81 out.push(ch);
82 let skip_to = i + e + 1;
83 while let Some((j, _)) = iter.peek() {
84 if *j < skip_to {
85 iter.next();
86 } else {
87 break;
88 }
89 }
90 }
91 _ => out.push('&'),
92 }
93 }
94 out
95}
96
97fn tag_name(raw: &str) -> String {
98 raw.split(|c: char| c.is_whitespace() || c == '/')
99 .next()
100 .unwrap_or("")
101 .to_ascii_lowercase()
102}
103
104#[derive(Default)]
105struct Collector {
106 blocks: Vec<Block>,
107 buf: String,
108 cell: String,
109 row: Option<Vec<String>>,
110 table: Option<Vec<Vec<String>>>,
111 heading: Option<u8>,
112 skip_depth: usize,
113}
114
115impl Collector {
116 fn flush(&mut self, kind: BlockKind) {
117 let text = self.buf.trim().to_string();
118 self.buf.clear();
119 if text.is_empty() {
120 return;
121 }
122 let mut block = Block {
123 kind,
124 content: text,
125 ..Default::default()
126 };
127 if kind == BlockKind::Heading {
128 block.level = self.heading.or(Some(1));
129 }
130 if kind == BlockKind::List {
133 block.ordered = Some(false);
134 }
135 self.blocks.push(block);
136 }
137
138 fn start_tag(&mut self, name: &str) {
139 if SKIP_TAGS.contains(&name) {
140 self.skip_depth += 1;
141 return;
142 }
143 if self.skip_depth > 0 {
144 return;
145 }
146 if let Some(level) = heading_level(name) {
147 self.flush(BlockKind::Paragraph);
148 self.heading = Some(level);
149 return;
150 }
151 match name {
152 "p" | "div" | "br" | "li" | "blockquote" => self.flush(BlockKind::Paragraph),
153 "pre" => {
154 self.flush(BlockKind::Paragraph);
155 }
157 "table" => {
158 self.flush(BlockKind::Paragraph);
159 self.table = Some(Vec::new());
160 }
161 "tr" => {
162 if self.table.is_some() {
163 self.row = Some(Vec::new());
164 }
165 }
166 "td" | "th" => self.cell.clear(),
167 _ => {}
168 }
169 }
170
171 fn end_tag(&mut self, name: &str) {
172 if SKIP_TAGS.contains(&name) {
173 self.skip_depth = self.skip_depth.saturating_sub(1);
174 return;
175 }
176 if self.skip_depth > 0 {
177 return;
178 }
179 if heading_level(name).is_some() {
180 self.flush(BlockKind::Heading);
181 self.heading = None;
182 return;
183 }
184 match name {
185 "pre" => self.flush(BlockKind::Code),
186 "li" => self.flush(BlockKind::List),
187 "td" | "th" => {
188 let cell = self.cell.trim().to_string();
189 if let Some(row) = self.row.as_mut() {
190 row.push(cell);
191 }
192 self.cell.clear();
193 }
194 "tr" => {
195 if let (Some(table), Some(row)) = (self.table.as_mut(), self.row.take()) {
196 table.push(row);
197 }
198 }
199 "table" => {
200 if let Some(rows) = self.table.take() {
201 if !rows.is_empty() {
202 let content = rows[0].join(" | ");
203 self.blocks.push(Block {
204 kind: BlockKind::Table,
205 content,
206 table: Some(TableData {
207 rows,
208 ..Default::default()
209 }),
210 ..Default::default()
211 });
212 }
213 }
214 }
215 "p" | "div" => self.flush(BlockKind::Paragraph),
216 "blockquote" => self.flush(BlockKind::Quote),
217 _ => {}
218 }
219 }
220
221 fn on_text(&mut self, data: &str) {
222 if self.skip_depth > 0 || data.is_empty() {
223 return;
224 }
225 let decoded = decode_entities(data);
226 if self.row.is_some() {
227 self.cell.push_str(&decoded); } else {
229 self.buf.push_str(&decoded);
230 }
231 }
232
233 fn finish(mut self) -> Vec<Block> {
234 self.flush(BlockKind::Paragraph);
235 self.blocks
236 }
237}
238
239fn strip_comments(text: &str) -> String {
240 let mut out = String::with_capacity(text.len());
241 let mut rest = text;
242 while let Some(start) = rest.find("<!--") {
243 out.push_str(&rest[..start]);
244 match rest[start + 4..].find("-->") {
245 Some(end) => rest = &rest[start + 4 + end + 3..],
246 None => {
247 rest = "";
248 break;
249 }
250 }
251 }
252 out.push_str(rest);
253 out
254}
255
256pub fn parse_blocks(text: &str) -> Vec<Block> {
258 let mut collector = Collector::default();
259 let bytes = text.as_bytes();
260 let mut i = 0usize;
261 let mut text_start = 0usize;
262
263 while i < bytes.len() {
264 if bytes[i] != b'<' {
265 i += 1;
266 continue;
267 }
268 if text_start < i {
269 collector.on_text(&text[text_start..i]);
270 }
271 if text[i..].starts_with("<!--") {
273 i = match text[i + 4..].find("-->") {
274 Some(rel) => i + 4 + rel + 3,
275 None => bytes.len(),
276 };
277 text_start = i;
278 continue;
279 }
280 match text[i..].find('>') {
281 Some(rel) => {
282 let raw = &text[i + 1..i + rel];
283 let trimmed = raw.trim();
284 if let Some(name) = trimmed.strip_prefix('/') {
285 collector.end_tag(&tag_name(name));
286 } else if !trimmed.is_empty()
287 && !trimmed.starts_with('!')
288 && !trimmed.starts_with('?')
289 {
290 collector.start_tag(&tag_name(trimmed));
291 }
292 i = i + rel + 1;
293 }
294 None => i = bytes.len(),
295 }
296 text_start = i;
297 }
298 if text_start < bytes.len() {
299 collector.on_text(&text[text_start..]);
300 }
301
302 let blocks = collector.finish();
303 if blocks.is_empty() {
304 let plain = strip_comments(text)
306 .split_whitespace()
307 .collect::<Vec<_>>()
308 .join(" ");
309 if plain.is_empty() {
310 return vec![];
311 }
312 return vec![Block {
313 kind: BlockKind::Paragraph,
314 content: plain,
315 ..Default::default()
316 }];
317 }
318 blocks
319}
320
321pub struct HtmlPlugin;
323
324impl FormatPlugin for HtmlPlugin {
325 fn id(&self) -> &str {
326 "html"
327 }
328 fn formats(&self) -> &[&str] {
329 &["html"]
330 }
331 fn default_parse_chain(&self) -> Vec<&'static str> {
332 vec!["html.parse"]
333 }
334 fn default_process_chain(&self) -> Vec<&'static str> {
335 vec![]
336 }
337 fn assemble(&self, raw: &RawDoc) -> Document {
338 let text = raw
339 .pages
340 .first()
341 .and_then(|p| p.items.first())
342 .map(|i| i.text.as_str())
343 .unwrap_or_default();
344 let blocks = parse_blocks(text);
345 let page_text = blocks
346 .iter()
347 .filter(|b| !b.content.is_empty())
348 .map(|b| b.content.as_str())
349 .collect::<Vec<_>>()
350 .join("\n\n");
351 Document {
352 pages: vec![Page {
353 blocks,
354 text: page_text,
355 ..Default::default()
356 }],
357 ..Default::default()
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn headings_paragraphs_and_lists() {
368 let html = "<html><head><title>t</title></head><body>
369 <h1>标题</h1><p>正文一</p><p>正文二</p>
370 <ul><li>甲</li><li>乙</li></ul>
371 <blockquote>引用</blockquote></body></html>";
372 let blocks = parse_blocks(html);
373 let kinds: Vec<BlockKind> = blocks.iter().map(|b| b.kind).collect();
374 assert_eq!(
375 kinds,
376 vec![
377 BlockKind::Heading,
378 BlockKind::Paragraph,
379 BlockKind::Paragraph,
380 BlockKind::List,
381 BlockKind::List,
382 BlockKind::Quote
383 ]
384 );
385 assert_eq!(blocks[0].content, "标题");
386 assert_eq!(blocks[0].level, Some(1));
387 assert_eq!(blocks[1].content, "正文一");
388 assert_eq!(blocks[3].content, "甲");
389 }
390
391 #[test]
392 fn table_rows_become_table_block() {
393 let html = "<table><tr><th>h1</th><th>h2</th></tr><tr><td>1</td><td>2</td></tr></table>";
394 let blocks = parse_blocks(html);
395 assert_eq!(blocks.len(), 1);
396 let rows = &blocks[0].table.as_ref().unwrap().rows;
397 assert_eq!(rows.len(), 2);
398 assert_eq!(rows[0], vec!["h1", "h2"]);
399 assert_eq!(rows[1], vec!["1", "2"]);
400 assert_eq!(blocks[0].content, "h1 | h2");
401 }
402
403 #[test]
404 fn script_style_and_entities() {
405 let html = "<style>p{color:red}</style><script>var a=1;</script><p>a & b <3 c A</p>";
406 let blocks = parse_blocks(html);
407 assert_eq!(blocks.len(), 1);
408 assert!(blocks[0].content.contains("a & b <3"));
409 assert!(blocks[0].content.ends_with("c A"));
410 }
411
412 #[test]
413 fn pre_keeps_inner_newlines() {
414 let html = "<pre>line1\nline2</pre>";
415 let blocks = parse_blocks(html);
416 assert_eq!(blocks[0].kind, BlockKind::Code);
417 assert_eq!(blocks[0].content, "line1\nline2");
418 }
419
420 #[test]
421 fn plain_text_fallback_and_comments() {
422 assert_eq!(parse_blocks("no tags here").len(), 1);
423 assert!(parse_blocks("<!-- only comment -->").is_empty());
424 let html = "<body><!-- x --><p>visible</p></body>";
425 assert_eq!(parse_blocks(html)[0].content, "visible");
426 }
427
428 #[test]
429 fn malformed_html_does_not_panic() {
430 let _ = parse_blocks("<p>unclosed <b>bold");
431 let _ = parse_blocks("<");
432 let _ = parse_blocks("<<>>");
433 }
434}