dynimg 0.1.9__tar.gz → 0.1.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {dynimg-0.1.9 → dynimg-0.1.11}/Cargo.lock +1 -1
  2. {dynimg-0.1.9 → dynimg-0.1.11}/Cargo.toml +2 -2
  3. dynimg-0.1.11/Makefile +34 -0
  4. {dynimg-0.1.9 → dynimg-0.1.11}/PKG-INFO +1 -1
  5. {dynimg-0.1.9 → dynimg-0.1.11}/examples/google-fonts.html +1 -1
  6. {dynimg-0.1.9 → dynimg-0.1.11}/pyproject.toml +1 -1
  7. {dynimg-0.1.9 → dynimg-0.1.11}/python/dynimg/__init__.py +3 -1
  8. {dynimg-0.1.9 → dynimg-0.1.11}/scripts/render-examples.sh +2 -0
  9. {dynimg-0.1.9 → dynimg-0.1.11}/src/lib.rs +31 -16
  10. {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/.gitignore +3 -0
  11. {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/test_dynimg.py +66 -19
  12. {dynimg-0.1.9 → dynimg-0.1.11}/.claude/settings.local.json +0 -0
  13. {dynimg-0.1.9 → dynimg-0.1.11}/.github/workflows/build-wheels.yml +0 -0
  14. {dynimg-0.1.9 → dynimg-0.1.11}/.github/workflows/ci.yml +0 -0
  15. {dynimg-0.1.9 → dynimg-0.1.11}/.github/workflows/release.yml +0 -0
  16. {dynimg-0.1.9 → dynimg-0.1.11}/.gitignore +0 -0
  17. {dynimg-0.1.9 → dynimg-0.1.11}/.pearls/CLAUDE.md +0 -0
  18. {dynimg-0.1.9 → dynimg-0.1.11}/.pearls/issues.jsonl +0 -0
  19. {dynimg-0.1.9 → dynimg-0.1.11}/CLAUDE.md +0 -0
  20. {dynimg-0.1.9 → dynimg-0.1.11}/LICENSE +0 -0
  21. {dynimg-0.1.9 → dynimg-0.1.11}/README.md +0 -0
  22. {dynimg-0.1.9 → dynimg-0.1.11}/codebook.toml +0 -0
  23. {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/PlaywriteINGuides-Regular.ttf +0 -0
  24. {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/RobotoMono-Bold.ttf +0 -0
  25. {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/RobotoMono-Bold.woff2 +0 -0
  26. {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/logo.svg +0 -0
  27. {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/servo.css +0 -0
  28. {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/style.css +0 -0
  29. {dynimg-0.1.9 → dynimg-0.1.11}/examples/inline-only.html +0 -0
  30. {dynimg-0.1.9 → dynimg-0.1.11}/examples/local-assets.html +0 -0
  31. {dynimg-0.1.9 → dynimg-0.1.11}/examples/local-font-woff2.html +0 -0
  32. {dynimg-0.1.9 → dynimg-0.1.11}/examples/local-font.html +0 -0
  33. {dynimg-0.1.9 → dynimg-0.1.11}/examples/mixed-assets.html +0 -0
  34. {dynimg-0.1.9 → dynimg-0.1.11}/examples/og-image.html +0 -0
  35. {dynimg-0.1.9 → dynimg-0.1.11}/examples/quote.html +0 -0
  36. {dynimg-0.1.9 → dynimg-0.1.11}/examples/remote-image.html +0 -0
  37. {dynimg-0.1.9 → dynimg-0.1.11}/examples/servo.html +0 -0
  38. {dynimg-0.1.9 → dynimg-0.1.11}/examples/social-card.html +0 -0
  39. {dynimg-0.1.9 → dynimg-0.1.11}/python/dynimg/__init__.pyi +0 -0
  40. {dynimg-0.1.9 → dynimg-0.1.11}/python/dynimg/py.typed +0 -0
  41. {dynimg-0.1.9 → dynimg-0.1.11}/src/main.rs +0 -0
  42. {dynimg-0.1.9 → dynimg-0.1.11}/src/python.rs +0 -0
  43. {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/README.md +0 -0
  44. {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/debug_parser.py +0 -0
  45. {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/debug_verbose.py +0 -0
  46. {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/test_from_ci.sh +0 -0
@@ -630,7 +630,7 @@ dependencies = [
630
630
 
631
631
  [[package]]
632
632
  name = "dynimg"
633
- version = "0.1.9"
633
+ version = "0.1.11"
634
634
  dependencies = [
635
635
  "anyhow",
636
636
  "anyrender",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "dynimg"
3
- version = "0.1.9"
3
+ version = "0.1.11"
4
4
  edition = "2024"
5
5
  description = "A fast library and CLI for rendering HTML/CSS to images"
6
6
  license = "MIT"
@@ -65,4 +65,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
65
65
  bytes = "1"
66
66
 
67
67
  [profile.release]
68
- lto = 'thin'
68
+ lto = "thin"
dynimg-0.1.11/Makefile ADDED
@@ -0,0 +1,34 @@
1
+ .PHONY: install dev test lint fmt clean build
2
+
3
+ # Install Python package into local venv
4
+ install:
5
+ maturin develop --release
6
+
7
+ # Install in development mode (faster builds, debug symbols)
8
+ dev:
9
+ maturin develop
10
+
11
+ # Run tests
12
+ test: install
13
+ cargo test
14
+ python test_wheels/test_dynimg.py
15
+
16
+ # Run lints
17
+ lint:
18
+ cargo clippy -- -D warnings
19
+ cargo fmt -- --check
20
+
21
+ # Format code
22
+ fmt:
23
+ cargo fmt
24
+
25
+ # Clean build artifacts
26
+ clean:
27
+ cargo clean
28
+ rm -rf dist/
29
+ rm -rf target/
30
+ rm -rf *.egg-info/
31
+
32
+ # Build release wheels
33
+ build:
34
+ maturin build --release
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dynimg
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -62,7 +62,7 @@
62
62
  <h1>Google Fonts Example</h1>
63
63
  <p>
64
64
  This example attempts to use
65
- <span class="highlight">Playfair Display</span> and
65
+ <span class="highlight">Playwrite IN Guides</span> and
66
66
  <span class="highlight">Inter</span> from Google Fonts.
67
67
  </p>
68
68
  <div class="note">
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "dynimg"
7
- version = "0.1.9"
7
+ version = "0.1.11"
8
8
  description = "A fast library for rendering HTML/CSS to images"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -32,4 +32,6 @@ __all__ = [
32
32
  "render_to_file",
33
33
  ]
34
34
 
35
- __version__ = "0.1.0"
35
+ from importlib.metadata import version as _get_version
36
+
37
+ __version__ = _get_version("dynimg")
@@ -71,6 +71,7 @@ echo ""
71
71
  echo "--- Mixed Assets (requires both flags) ---"
72
72
  run_test "Without flags" false "$DYNIMG" "$EXAMPLES_DIR/mixed-assets.html" -o "$OUTPUT_DIR/mixed-no-flags.png"
73
73
  run_test "With --allow-net only" false "$DYNIMG" "$EXAMPLES_DIR/mixed-assets.html" -o "$OUTPUT_DIR/mixed-net-only.png" --allow-net
74
+ run_test "With --allow-net only" false "$DYNIMG" "$EXAMPLES_DIR/mixed-assets.html" -o "$OUTPUT_DIR/mixed-net-only.webp" --allow-net
74
75
  run_test "With --assets only" false "$DYNIMG" "$EXAMPLES_DIR/mixed-assets.html" -o "$OUTPUT_DIR/mixed-assets-only.png" --assets "$ASSETS_DIR"
75
76
  run_test "With both flags" false "$DYNIMG" "$EXAMPLES_DIR/mixed-assets.html" -o "$OUTPUT_DIR/mixed-both-flags.png" --allow-net --assets "$ASSETS_DIR"
76
77
  run_test "With both flags" false "$DYNIMG" "$EXAMPLES_DIR/google-fonts.html" -o "$OUTPUT_DIR/google-fonts.png" --allow-net --assets "$ASSETS_DIR"
@@ -78,6 +79,7 @@ run_test "With both flags" false "$DYNIMG" "$EXAMPLES_DIR/google-fonts.html" -o
78
79
  echo ""
79
80
  echo "--- OG Image Templates ---"
80
81
  run_test "og-image.html" false "$DYNIMG" "$EXAMPLES_DIR/og-image.html" -o "$OUTPUT_DIR/og-image.png"
82
+ run_test "og-image.html" false "$DYNIMG" "$EXAMPLES_DIR/og-image.html" -o "$OUTPUT_DIR/og-image.webp"
81
83
  run_test "social-card.html" false "$DYNIMG" "$EXAMPLES_DIR/social-card.html" -o "$OUTPUT_DIR/social-card.png"
82
84
  run_test "quote.html" false "$DYNIMG" "$EXAMPLES_DIR/quote.html" -o "$OUTPUT_DIR/quote.png"
83
85
 
@@ -451,18 +451,22 @@ async fn render_document(
451
451
  ) -> Result<RenderedImage, Error> {
452
452
  // Resolve resource requests
453
453
  if let Some(p) = provider {
454
- loop {
454
+ // Wait for all network requests including cascading requests.
455
+ // CSS stylesheets may trigger font fetches when processed, so we need
456
+ // multiple consecutive "empty" checks to ensure all cascading requests complete.
457
+ // Using 5 cycles provides safety margin for complex pages with many resources.
458
+ let mut consecutive_empty = 0u32;
459
+ const REQUIRED_EMPTY_CYCLES: u32 = 5;
460
+
461
+ while consecutive_empty < REQUIRED_EMPTY_CYCLES {
455
462
  document.resolve(0.0);
456
- if p.is_empty() {
457
- break;
458
- }
459
463
  tokio::time::sleep(std::time::Duration::from_millis(1)).await;
460
- }
461
464
 
462
- // Extra resolve cycles for font registration
463
- for _ in 0..3 {
464
- tokio::time::sleep(std::time::Duration::from_millis(5)).await;
465
- document.resolve(0.0);
465
+ if p.is_empty() {
466
+ consecutive_empty += 1;
467
+ } else {
468
+ consecutive_empty = 0;
469
+ }
466
470
  }
467
471
  }
468
472
 
@@ -521,11 +525,13 @@ fn write_png(path: &Path, buffer: &[u8], width: u32, height: u32) -> Result<(),
521
525
  fn encode_png(buffer: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Error> {
522
526
  const PPM: u32 = (144.0 * 39.3701) as u32;
523
527
 
524
- let mut output = Vec::new();
528
+ // Pre-allocate output (PNG is typically 10-50% of raw size after compression)
529
+ let mut output = Vec::with_capacity(buffer.len() / 4);
525
530
  {
526
531
  let mut encoder = png::Encoder::new(&mut output, width, height);
527
532
  encoder.set_color(png::ColorType::Rgba);
528
533
  encoder.set_depth(png::BitDepth::Eight);
534
+ encoder.set_compression(png::Compression::Fast);
529
535
  encoder.set_pixel_dims(Some(png::PixelDimensions {
530
536
  xppu: PPM,
531
537
  yppu: PPM,
@@ -552,14 +558,19 @@ fn write_jpeg(
552
558
  }
553
559
 
554
560
  fn encode_jpeg(buffer: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, Error> {
555
- let rgb_buffer: Vec<u8> = buffer
556
- .chunks(4)
557
- .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
558
- .collect();
561
+ // Pre-allocate RGB buffer (3 bytes per pixel instead of 4)
562
+ let pixel_count = (width * height) as usize;
563
+ let mut rgb_buffer = Vec::with_capacity(pixel_count * 3);
564
+
565
+ // Convert RGBA to RGB in-place
566
+ for chunk in buffer.chunks_exact(4) {
567
+ rgb_buffer.extend_from_slice(&chunk[..3]);
568
+ }
559
569
 
560
570
  let img = image::RgbImage::from_raw(width, height, rgb_buffer).ok_or(Error::InvalidBuffer)?;
561
571
 
562
- let mut output = Vec::new();
572
+ // Pre-allocate output (estimate ~10% of raw size for compressed JPEG)
573
+ let mut output = Vec::with_capacity(pixel_count / 10);
563
574
  let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality);
564
575
  encoder.encode_image(&img)?;
565
576
 
@@ -574,6 +585,10 @@ fn write_webp_lossless(path: &Path, buffer: &[u8], width: u32, height: u32) -> R
574
585
 
575
586
  fn encode_webp_lossless(buffer: &[u8], width: u32, height: u32) -> Vec<u8> {
576
587
  let encoder = webp::Encoder::from_rgba(buffer, width, height);
577
- let webp_data = encoder.encode_lossless();
588
+ let mut config = webp::WebPConfig::new().unwrap();
589
+ config.lossless = 1;
590
+ config.quality = 75.0;
591
+ config.method = 0; // 0=fastest, 6=slowest (default)
592
+ let webp_data = encoder.encode_advanced(&config).unwrap();
578
593
  webp_data.to_vec()
579
594
  }
@@ -2,3 +2,6 @@
2
2
  *.whl
3
3
  .venv/
4
4
  test_output.*
5
+ *.jpg
6
+ *.webp
7
+ *.png
@@ -2,6 +2,20 @@
2
2
  """Test script for dynimg wheels."""
3
3
 
4
4
  import sys
5
+ import time
6
+ from contextlib import contextmanager
7
+
8
+
9
+ class RunTimer:
10
+ def __init__(self):
11
+ self.start = time.perf_counter()
12
+
13
+ @contextmanager
14
+ def measure(self, name):
15
+ t0 = time.perf_counter()
16
+ yield
17
+ t1 = time.perf_counter()
18
+ print(f"[{t1 - self.start:.3f}s] {name} took {(t1 - t0) * 1000:.2f}ms")
5
19
 
6
20
 
7
21
  def test_import():
@@ -17,11 +31,26 @@ def test_render_basic():
17
31
  """Test basic rendering."""
18
32
  import dynimg
19
33
 
34
+ timer = RunTimer()
20
35
  html = '<html><body style="background:blue;"><h1>Test</h1></body></html>'
21
- img = dynimg.render(html, dynimg.RenderOptions(width=100, height=100, scale=1.0))
36
+
37
+ with timer.measure("Render"):
38
+ img = dynimg.render(
39
+ html, dynimg.RenderOptions(width=100, height=100, scale=1.0)
40
+ )
22
41
 
23
42
  assert img.width == 100, f"Expected width 100, got {img.width}"
24
43
  assert img.height == 100, f"Expected height 100, got {img.height}"
44
+
45
+ with timer.measure("Save PNG"):
46
+ img.save_png("test_basic.png")
47
+
48
+ with timer.measure("Save JPEG"):
49
+ img.save_jpeg("test_basic.jpg")
50
+
51
+ with timer.measure("Save WebP"):
52
+ img.save_webp("test_basic.webp")
53
+
25
54
  print(f"Basic render: {img.width}x{img.height}")
26
55
  return True
27
56
 
@@ -30,6 +59,7 @@ def test_render_gradient():
30
59
  """Test gradient rendering."""
31
60
  import dynimg
32
61
 
62
+ timer = RunTimer()
33
63
  html = """
34
64
  <html>
35
65
  <body style="background: linear-gradient(135deg, #667eea, #764ba2);
@@ -42,10 +72,21 @@ def test_render_gradient():
42
72
  </html>
43
73
  """
44
74
  options = dynimg.RenderOptions(width=1200, height=630, scale=2.0)
45
- img = dynimg.render(html, options)
75
+
76
+ with timer.measure("Render"):
77
+ img = dynimg.render(html, options)
46
78
 
47
79
  assert img.width == 2400, f"Expected width 2400, got {img.width}"
48
80
  assert img.height == 1260, f"Expected height 1260, got {img.height}"
81
+ with timer.measure("Save WebP"):
82
+ img.save_webp("test_gradient.webp")
83
+
84
+ with timer.measure("Save PNG"):
85
+ img.save_png("test_gradient.png")
86
+
87
+ with timer.measure("Save JPEG"):
88
+ img.save_jpeg("test_gradient.jpg")
89
+
49
90
  print(f"Gradient render: {img.width}x{img.height}")
50
91
  return True
51
92
 
@@ -56,32 +97,32 @@ def test_save_formats():
56
97
 
57
98
  import dynimg
58
99
 
100
+ timer = RunTimer()
59
101
  html = '<html><body style="background:red; width:50px; height:50px;"></body></html>'
60
- img = dynimg.render(html, dynimg.RenderOptions(width=50, height=50, scale=1.0))
102
+ with timer.measure("Render"):
103
+ img = dynimg.render(html, dynimg.RenderOptions(width=50, height=50, scale=1.0))
61
104
 
62
105
  # Test PNG
63
- img.save_png("test_output.png")
106
+ with timer.measure("Save PNG"):
107
+ img.save_png("test_output.png")
64
108
  assert os.path.exists("test_output.png"), "PNG file not created"
65
109
  png_size = os.path.getsize("test_output.png")
66
110
  print(f"PNG saved: {png_size} bytes")
67
111
 
68
112
  # Test WebP
69
- img.save_webp("test_output.webp")
113
+ with timer.measure("Save WebP"):
114
+ img.save_webp("test_output.webp")
70
115
  assert os.path.exists("test_output.webp"), "WebP file not created"
71
116
  webp_size = os.path.getsize("test_output.webp")
72
117
  print(f"WebP saved: {webp_size} bytes")
73
118
 
74
119
  # Test JPEG
75
- img.save_jpeg("test_output.jpg", quality=90)
120
+ with timer.measure("Save JPEG"):
121
+ img.save_jpeg("test_output.jpg", quality=90)
76
122
  assert os.path.exists("test_output.jpg"), "JPEG file not created"
77
123
  jpeg_size = os.path.getsize("test_output.jpg")
78
124
  print(f"JPEG saved: {jpeg_size} bytes")
79
125
 
80
- # Cleanup
81
- os.remove("test_output.png")
82
- os.remove("test_output.webp")
83
- os.remove("test_output.jpg")
84
-
85
126
  return True
86
127
 
87
128
 
@@ -89,10 +130,13 @@ def test_to_bytes():
89
130
  """Test encoding to bytes."""
90
131
  import dynimg
91
132
 
133
+ timer = RunTimer()
92
134
  html = (
93
135
  '<html><body style="background:green; width:50px; height:50px;"></body></html>'
94
136
  )
95
- img = dynimg.render(html, dynimg.RenderOptions(width=50, height=50, scale=1.0))
137
+
138
+ with timer.measure("Render"):
139
+ img = dynimg.render(html, dynimg.RenderOptions(width=50, height=50, scale=1.0))
96
140
 
97
141
  png_bytes = img.to_png()
98
142
  assert len(png_bytes) > 0, "PNG bytes empty"
@@ -118,21 +162,24 @@ def test_render_to_file():
118
162
 
119
163
  import dynimg
120
164
 
165
+ timer = RunTimer()
121
166
  html = (
122
167
  '<html><body style="background:yellow; width:50px; height:50px;"></body></html>'
123
168
  )
124
169
 
125
- dynimg.render_to_file(html, "test_direct.png")
170
+ with timer.measure("render_to_file PNG"):
171
+ dynimg.render_to_file(html, "test_direct.png")
126
172
  assert os.path.exists("test_direct.png"), "Direct PNG not created"
127
173
  print(f"render_to_file PNG: {os.path.getsize('test_direct.png')} bytes")
128
174
  os.remove("test_direct.png")
129
175
 
130
- dynimg.render_to_file(
131
- html,
132
- "test_direct.webp",
133
- options=dynimg.RenderOptions(width=100, height=100, scale=1.0),
134
- quality=85,
135
- )
176
+ with timer.measure("render_to_file WebP"):
177
+ dynimg.render_to_file(
178
+ html,
179
+ "test_direct.webp",
180
+ options=dynimg.RenderOptions(width=100, height=100, scale=1.0),
181
+ quality=85,
182
+ )
136
183
  assert os.path.exists("test_direct.webp"), "Direct WebP not created"
137
184
  print(f"render_to_file WebP: {os.path.getsize('test_direct.webp')} bytes")
138
185
  os.remove("test_direct.webp")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes