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.
- {dynimg-0.1.9 → dynimg-0.1.11}/Cargo.lock +1 -1
- {dynimg-0.1.9 → dynimg-0.1.11}/Cargo.toml +2 -2
- dynimg-0.1.11/Makefile +34 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/PKG-INFO +1 -1
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/google-fonts.html +1 -1
- {dynimg-0.1.9 → dynimg-0.1.11}/pyproject.toml +1 -1
- {dynimg-0.1.9 → dynimg-0.1.11}/python/dynimg/__init__.py +3 -1
- {dynimg-0.1.9 → dynimg-0.1.11}/scripts/render-examples.sh +2 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/src/lib.rs +31 -16
- {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/.gitignore +3 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/test_dynimg.py +66 -19
- {dynimg-0.1.9 → dynimg-0.1.11}/.claude/settings.local.json +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/.github/workflows/build-wheels.yml +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/.github/workflows/ci.yml +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/.github/workflows/release.yml +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/.gitignore +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/.pearls/CLAUDE.md +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/.pearls/issues.jsonl +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/CLAUDE.md +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/LICENSE +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/README.md +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/codebook.toml +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/PlaywriteINGuides-Regular.ttf +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/RobotoMono-Bold.ttf +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/RobotoMono-Bold.woff2 +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/logo.svg +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/servo.css +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/assets/style.css +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/inline-only.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/local-assets.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/local-font-woff2.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/local-font.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/mixed-assets.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/og-image.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/quote.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/remote-image.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/servo.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/examples/social-card.html +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/python/dynimg/__init__.pyi +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/python/dynimg/py.typed +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/src/main.rs +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/src/python.rs +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/README.md +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/debug_parser.py +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/debug_verbose.py +0 -0
- {dynimg-0.1.9 → dynimg-0.1.11}/test_wheels/test_from_ci.sh +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "dynimg"
|
|
3
|
-
version = "0.1.
|
|
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 =
|
|
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
|
|
@@ -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">
|
|
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">
|
|
@@ -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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
File without changes
|