1use std::fmt::Write;
2
3use serde::{Deserialize, Serialize};
4
5fn feq(a: f32, b: f32) -> bool { (a - b).abs() < 0.01 }
6
7use crate::types::{StateTag, Style, Viewport};
8
9#[derive(Serialize, Deserialize, Debug, Clone)]
11pub struct EcsFrame {
12 pub tab_id: u64,
13 pub viewport: Viewport,
14 pub scroll_offset: (f32, f32),
15 pub total_extent: (f32, f32),
16 pub entities: EntityArray,
17 pub content_flags: u16,
18 pub form_entries: Vec<FormEntry>,
19}
20
21#[derive(Serialize, Deserialize, Debug, Clone, Default)]
23pub struct FormEntry {
24 pub entity_id: u64,
25 pub name: String,
26 pub input_type: String,
27 pub value: String,
28 pub placeholder: String,
29 pub required: bool,
30}
31
32#[derive(Serialize, Deserialize, Debug, Clone)]
43pub struct EntityArray {
44 pub ids: Vec<u64>,
45 pub xs: Vec<f32>,
46 pub ys: Vec<f32>,
47 pub widths: Vec<f32>,
48 pub heights: Vec<f32>,
49 pub z_indices: Vec<u16>,
50 pub styles: Vec<Style>,
51 pub state_tags: Vec<u8>,
52 pub content_hashes: Vec<u64>,
53 pub content_offsets: Vec<u32>,
54 pub contents: Vec<u8>,
55}
56
57impl EntityArray {
58 pub fn len(&self) -> usize {
59 self.ids.len()
60 }
61
62 pub fn is_empty(&self) -> bool {
63 self.ids.is_empty()
64 }
65
66 pub fn clear(&mut self) {
67 self.ids.clear();
68 self.xs.clear();
69 self.ys.clear();
70 self.widths.clear();
71 self.heights.clear();
72 self.z_indices.clear();
73 self.styles.clear();
74 self.state_tags.clear();
75 self.content_hashes.clear();
76 self.content_offsets.clear();
77 self.contents.clear();
78 }
79
80 pub fn shrink_to_fit(&mut self) {
81 self.ids.shrink_to_fit();
82 self.xs.shrink_to_fit();
83 self.ys.shrink_to_fit();
84 self.widths.shrink_to_fit();
85 self.heights.shrink_to_fit();
86 self.z_indices.shrink_to_fit();
87 self.styles.shrink_to_fit();
88 self.state_tags.shrink_to_fit();
89 self.content_hashes.shrink_to_fit();
90 self.content_offsets.shrink_to_fit();
91 self.contents.shrink_to_fit();
92 }
93
94 pub fn push(
95 &mut self,
96 id: u64,
97 x: f32,
98 y: f32,
99 w: f32,
100 h: f32,
101 z: u16,
102 style: Style,
103 content: &[u8],
104 ) {
105 let hash = xxhash_rust::xxh3::xxh3_64(content);
106 self.ids.push(id);
107 self.xs.push(x);
108 self.ys.push(y);
109 self.widths.push(w);
110 self.heights.push(h);
111 self.z_indices.push(z);
112 self.styles.push(style);
113 self.state_tags.push(StateTag::CHANGED.0);
114 self.content_hashes.push(hash);
115 self.content_offsets.push(self.contents.len() as u32);
116 self.contents.extend_from_slice(content);
117 }
118
119 pub fn diff_since(&self, prev: &EntityArray) -> ChangedEntities {
122 let n = self.ids.len();
123 if n > 100 && n == prev.ids.len()
125 && self.ids.as_slice() == prev.ids.as_slice()
126 && self.xs.as_slice() == prev.xs.as_slice()
127 && self.ys.as_slice() == prev.ys.as_slice()
128 && self.widths.as_slice() == prev.widths.as_slice()
129 && self.heights.as_slice() == prev.heights.as_slice()
130 && self.z_indices.as_slice() == prev.z_indices.as_slice()
131 && self.styles.as_slice() == prev.styles.as_slice()
132 && self.content_hashes.as_slice() == prev.content_hashes.as_slice()
133 {
134 return ChangedEntities::default();
135 }
136 let mut out = ChangedEntities::default();
137
138 let mut prev_idx: std::collections::HashMap<u64, usize> =
139 std::collections::HashMap::with_capacity(prev.ids.len());
140 for (i, &id) in prev.ids.iter().enumerate() {
141 prev_idx.insert(id, i);
142 }
143
144 for i in 0..n {
145 let id = self.ids[i];
146 let hash = self.content_hashes[i];
147 let prev_i = prev_idx.get(&id).copied();
148 let state = match prev_i {
149 None => StateTag::CHANGED,
150 Some(pi) => {
151 if hash == prev.content_hashes[pi]
152 && feq(self.xs[i], prev.xs[pi])
153 && feq(self.ys[i], prev.ys[pi])
154 && feq(self.widths[i], prev.widths[pi])
155 && feq(self.heights[i], prev.heights[pi])
156 && self.z_indices[i] == prev.z_indices[pi]
157 && self.styles[i] == prev.styles[pi]
158 {
159 continue; }
161 if hash == prev.content_hashes[pi] {
162 StateTag::MOVED
163 } else {
164 StateTag::CHANGED
165 }
166 }
167 };
168
169 out.ids.push(id);
170 out.state_tags.push(state.0);
171 out.xs.push(self.xs[i]);
172 out.ys.push(self.ys[i]);
173 out.widths.push(self.widths[i]);
174 out.heights.push(self.heights[i]);
175 out.z_indices.push(self.z_indices[i]);
176 out.styles.push(self.styles[i]);
177 out.content_hashes.push(hash);
178 if state == StateTag::CHANGED {
179 let slice = self.content_slice(i);
180 out.new_content.push(slice.to_vec());
181 } else {
182 out.new_content.push(Vec::new());
183 }
184 }
185
186 let current_ids: std::collections::HashSet<u64> = self.ids.iter().copied().collect();
188 for &id in &prev.ids {
189 if !current_ids.contains(&id) {
190 out.ids.push(id);
191 out.state_tags.push(StateTag::REMOVED.0);
192 out.xs.push(0.0); out.ys.push(0.0);
193 out.widths.push(0.0); out.heights.push(0.0);
194 out.z_indices.push(0); out.styles.push(Style(0));
195 out.content_hashes.push(0);
196 out.new_content.push(Vec::new());
197 }
198 }
199 out
200 }
201
202 pub fn content_slice(&self, idx: usize) -> &[u8] {
203 if idx >= self.ids.len() { return &[]; }
204 let start = self.content_offsets[idx] as usize;
205 let end = if idx + 1 < self.ids.len() {
206 self.content_offsets[idx + 1] as usize
207 } else {
208 self.contents.len()
209 };
210 &self.contents[start..end]
211 }
212
213 pub fn unpack_entity(&self, idx: usize) -> (&[u8], &[u8], &[u8], &[u8], &[u8], &[u8]) {
217 let slice = self.content_slice(idx);
218 if slice.is_empty() { return (b"", b"", b"", b"", b"", slice); }
219 if slice[0] != 0 { return (b"", b"", b"", b"", b"", slice); }
220 let tag_end = slice.iter().skip(1).position(|&b| b == 0).map(|p| p + 1).unwrap_or(1);
221 let tag = &slice[1..tag_end];
222 let mut rest = &slice[tag_end + 1..];
223
224 let classes: &[u8];
226 if rest.first() == Some(&0) { classes = b""; rest = &rest[1..]; }
227 else {
228 let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
229 classes = &rest[..e];
230 rest = &rest[(e + 1).min(rest.len())..];
231 }
232 let id: &[u8];
234 if rest.first() == Some(&0) { id = b""; rest = &rest[1..]; }
235 else {
236 let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
237 id = &rest[..e];
238 rest = &rest[(e + 1).min(rest.len())..];
239 }
240 let href: &[u8];
242 if rest.first() == Some(&0) { href = b""; rest = &rest[1..]; }
243 else {
244 let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
245 href = &rest[..e];
246 rest = &rest[(e + 1).min(rest.len())..];
247 }
248 let role: &[u8];
250 if rest.first() == Some(&0) { role = b""; rest = &rest[1..]; }
251 else {
252 let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
253 role = &rest[..e];
254 rest = &rest[(e + 1).min(rest.len())..];
255 }
256 (tag, classes, id, href, role, rest)
257 }
258}
259
260impl Default for EntityArray {
261 fn default() -> Self {
262 Self::with_capacity(512)
263 }
264}
265
266impl EntityArray {
267 pub fn with_capacity(n: usize) -> Self {
268 let cap = n.max(16);
269 Self {
270 ids: Vec::with_capacity(cap),
271 xs: Vec::with_capacity(cap),
272 ys: Vec::with_capacity(cap),
273 widths: Vec::with_capacity(cap),
274 heights: Vec::with_capacity(cap),
275 z_indices: Vec::with_capacity(cap),
276 styles: Vec::with_capacity(cap),
277 state_tags: Vec::with_capacity(cap),
278 content_hashes: Vec::with_capacity(cap),
279 content_offsets: Vec::with_capacity(cap),
280 contents: Vec::with_capacity(cap * 16),
281 }
282 }
283
284 fn json_esc_buf(buf: &mut String, s: &[u8]) {
285 let s = std::str::from_utf8(s).unwrap_or("");
286 for c in s.chars() {
287 match c {
288 '"' => buf.push_str("\\\""),
289 '\\' => buf.push_str("\\\\"),
290 '\n' => buf.push_str("\\n"),
291 '\r' => buf.push_str("\\r"),
292 '\t' => buf.push_str("\\t"),
293 '\x08' => buf.push_str("\\b"),
294 '\x0C' => buf.push_str("\\f"),
295 c if c.is_control() => { let _ = write!(buf, "\\u{:04x}", c as u32); }
296 c => buf.push(c),
297 }
298 }
299 }
300
301 pub fn hydration_json(&self) -> String {
304 let mut out = String::with_capacity(self.ids.len() * 80);
305 out.push('[');
306 let mut parents: Vec<Option<usize>> = vec![None; self.ids.len()];
307 let mut stack: Vec<usize> = Vec::with_capacity(16);
308 let mut last_was_block = false;
309 for i in 0..self.ids.len() {
310 if let Some(&top) = stack.last() { parents[i] = Some(top); }
311 if self.styles[i].display_type() == 0 {
312 if last_was_block {
313 stack.pop();
314 }
315 stack.push(i);
316 last_was_block = true;
317 } else {
318 last_was_block = false;
319 }
320 }
321
322 let mut first = true;
323 for i in 0..self.ids.len() {
324 let style = self.styles[i];
325 if style.display_type() == 3 { continue; }
326 let (tag, _, _, href, role, text) = self.unpack_entity(i);
327 let parent_id = parents[i].map(|p| self.ids[p] as i64).unwrap_or(0);
328
329 if !first { out.push(','); } first = false;
330 write!(out, "{{\"i\":{}", self.ids[i]).ok();
331 out.push_str(",\"t\":\"");
332 Self::json_esc_buf(&mut out, tag);
333 out.push('"');
334 if !text.is_empty() && !text.iter().all(|&b| b == b' ') {
335 out.push_str(",\"x\":\"");
336 Self::json_esc_buf(&mut out, text);
337 out.push('"');
338 }
339 if !href.is_empty() {
340 out.push_str(",\"h\":\"");
341 Self::json_esc_buf(&mut out, href);
342 out.push('"');
343 }
344 if !role.is_empty() {
345 out.push_str(",\"r\":\"");
346 Self::json_esc_buf(&mut out, role);
347 out.push('"');
348 }
349 if parent_id != 0 { write!(out, ",\"p\":{}", parent_id).ok(); }
350 out.push('}');
351 }
352 out.push(']');
353 out
354 }
355}
356
357#[derive(Serialize, Deserialize, Debug, Clone, Default)]
361pub struct ChangedEntities {
362 pub ids: Vec<u64>,
363 pub state_tags: Vec<u8>,
364 pub xs: Vec<f32>,
365 pub ys: Vec<f32>,
366 pub widths: Vec<f32>,
367 pub heights: Vec<f32>,
368 pub z_indices: Vec<u16>,
369 pub styles: Vec<Style>,
370 pub content_hashes: Vec<u64>,
371 pub new_content: Vec<Vec<u8>>,
373}
374
375impl ChangedEntities {
376 pub fn len(&self) -> usize {
377 self.ids.len()
378 }
379 pub fn is_empty(&self) -> bool {
380 self.ids.is_empty()
381 }
382}
383
384#[derive(Serialize, Deserialize, Debug, Clone, Default)]
387pub struct PageMetadata {
388 pub title: String,
389 pub url: String,
390 pub description: String,
391 pub og_title: String,
392 pub og_image: String,
393 pub og_description: String,
394 pub canonical_url: String,
395 pub json_ld: Vec<String>,
396}
397
398#[derive(Clone, Copy, Debug, PartialEq, Eq)]
402pub enum MetadataLevel {
403 Minimal,
404 Full,
405}
406
407impl Default for MetadataLevel {
408 fn default() -> Self {
409 Self::Minimal
410 }
411}
412
413impl std::str::FromStr for MetadataLevel {
414 type Err = &'static str;
415 fn from_str(s: &str) -> Result<Self, Self::Err> {
416 match s.to_ascii_lowercase().as_str() {
417 "full" => Ok(Self::Full),
418 _ => Ok(Self::Minimal),
419 }
420 }
421}
422
423pub const FLAG_HAS_VIDEO: u16 = 1;
426pub const FLAG_HAS_CANVAS: u16 = 2;
427pub const FLAG_HAS_IFRAME: u16 = 4;
428pub const FLAG_HAS_LAZY_IMAGES: u16 = 8;
429pub const FLAG_HAS_PAYWALL: u16 = 16;
430pub const FLAG_HAS_LOGIN_WALL: u16 = 32;
431pub const FLAG_IS_SPA: u16 = 64;
432pub const FLAG_HAS_TABLES: u16 = 128;
433pub const FLAG_IS_ARTICLE: u16 = 256;
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_unpack_entity_roundtrip() {
441 let mut arr = EntityArray::with_capacity(4);
442 let packed = {
443 let mut p = Vec::new();
444 p.push(0u8);
445 p.extend_from_slice(b"div"); p.push(0u8);
446 p.extend_from_slice(b"container main"); p.push(0u8);
447 p.extend_from_slice(b"my-id"); p.push(0u8);
448 p.extend_from_slice(b"https://example.com"); p.push(0u8);
449 p.extend_from_slice(b"navigation"); p.push(0u8);
450 p.extend_from_slice(b"Hello world");
451 p
452 };
453 arr.push(1, 0.0, 0.0, 0.0, 0.0, 0, Style(0), &packed);
454
455 let (tag, classes, id, href, role, text) = arr.unpack_entity(0);
456 assert_eq!(tag, b"div");
457 assert_eq!(classes, b"container main");
458 assert_eq!(id, b"my-id");
459 assert_eq!(href, b"https://example.com");
460 assert_eq!(role, b"navigation");
461 assert_eq!(text, b"Hello world");
462 }
463
464 #[test]
465 fn test_diff_since_static_unchanged() {
466 let mut prev = EntityArray::with_capacity(2);
467 let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
468 prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
469 let p2 = build_packed(b"p", b"", b"", b"", b"", b"world");
470 prev.push(2, 10.0, 40.0, 100.0, 20.0, 0, Style(0), &p2);
471
472 let curr = prev.clone();
473 let delta = curr.diff_since(&prev);
474 assert!(delta.is_empty());
475 }
476
477 #[test]
478 fn test_diff_since_moved() {
479 let mut prev = EntityArray::with_capacity(2);
480 let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
481 prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
482
483 let mut curr = EntityArray::with_capacity(2);
484 curr.push(1, 15.0, 25.0, 100.0, 20.0, 0, Style(0), &p1);
485
486 let delta = curr.diff_since(&prev);
487 assert_eq!(delta.len(), 1);
488 assert_eq!(delta.state_tags[0], StateTag::MOVED.0);
489 }
490
491 #[test]
492 fn test_diff_since_changed() {
493 let mut prev = EntityArray::with_capacity(2);
494 let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
495 prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
496
497 let mut curr = EntityArray::with_capacity(2);
498 let p2 = build_packed(b"p", b"", b"", b"", b"", b"goodbye");
499 curr.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p2);
500
501 let delta = curr.diff_since(&prev);
502 assert_eq!(delta.len(), 1);
503 assert_eq!(delta.state_tags[0], StateTag::CHANGED.0);
504 assert!(!delta.new_content[0].is_empty());
505 }
506
507 #[test]
508 fn test_diff_since_removed() {
509 let mut prev = EntityArray::with_capacity(2);
510 let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
511 prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
512 let p2 = build_packed(b"p", b"", b"", b"", b"", b"world");
513 prev.push(2, 10.0, 40.0, 100.0, 20.0, 0, Style(0), &p2);
514
515 let mut curr = EntityArray::with_capacity(1);
516 curr.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
517
518 let delta = curr.diff_since(&prev);
519 assert_eq!(delta.len(), 1);
520 assert_eq!(delta.state_tags[0], StateTag::REMOVED.0);
521 }
522
523 fn build_packed(tag: &[u8], classes: &[u8], id: &[u8], href: &[u8], role: &[u8], text: &[u8]) -> Vec<u8> {
524 let mut p = Vec::new();
525 p.push(0u8);
526 p.extend_from_slice(tag); p.push(0u8);
527 p.extend_from_slice(classes); p.push(0u8);
528 p.extend_from_slice(id); p.push(0u8);
529 p.extend_from_slice(href); p.push(0u8);
530 p.extend_from_slice(role); p.push(0u8);
531 p.extend_from_slice(text);
532 p
533 }
534}