From 56560436509fb9deefdab49beb5ba743043e8e43 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Fri, 4 Jul 2025 18:32:41 +0200 Subject: [PATCH 1/7] feat: PHP bindings Signed-off-by: Dmitry Dygalo --- .github/workflows/build.yml | 129 ++++++++++++++++++++++++ bindings/php/.cargo/config.toml | 12 +++ bindings/php/.gitignore | 3 + bindings/php/Cargo.toml | 18 ++++ bindings/php/README.md | 26 +++++ bindings/php/benchmarks/InlineBench.php | 47 +++++++++ bindings/php/composer.json | 28 +++++ bindings/php/phpbench.json | 7 ++ bindings/php/phpunit.xml | 11 ++ bindings/php/src/lib.rs | 87 ++++++++++++++++ bindings/php/stubs/css_inline.php | 21 ++++ bindings/php/tests/CssInlineTest.php | 92 +++++++++++++++++ 12 files changed, 481 insertions(+) create mode 100644 bindings/php/.cargo/config.toml create mode 100644 bindings/php/.gitignore create mode 100644 bindings/php/Cargo.toml create mode 100644 bindings/php/README.md create mode 100644 bindings/php/benchmarks/InlineBench.php create mode 100644 bindings/php/composer.json create mode 100644 bindings/php/phpbench.json create mode 100644 bindings/php/phpunit.xml create mode 100644 bindings/php/src/lib.rs create mode 100644 bindings/php/stubs/css_inline.php create mode 100644 bindings/php/tests/CssInlineTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 962e6581..a35ca0c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -531,6 +531,135 @@ jobs: DEFAULT_CROSS_BUILD_ENV_URL: "https://github.com/pyodide/pyodide/releases/download/0.28.0a3/xbuildenv-0.28.0a3.tar.bz2" RUSTFLAGS: "-C link-arg=-sSIDE_MODULE=2 -Z link-native-libraries=no -Z emscripten-wasm-eh" + test-php: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-13] + php-version: ["8.2", "8.3", "8.4"] + clang: ["20"] + + name: PHP ${{ matrix.php-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: bindings/php + + - name: Cache LLVM and Clang + id: cache-llvm + uses: actions/cache@v4 + if: matrix.os == 'ubuntu-22.04' + with: + path: ${{ runner.temp }}/llvm-${{ matrix.clang }} + key: ${{ matrix.os }}-llvm-${{ matrix.clang }} + + - name: Setup LLVM & Clang + id: clang + uses: KyleMayes/install-llvm-action@v2 + if: matrix.os == 'ubuntu-22.04' + with: + version: ${{ matrix.clang }} + directory: ${{ runner.temp }}/llvm-${{ matrix.clang }} + cached: ${{ steps.cache-llvm.outputs.cache-hit }} + + - name: Configure Clang + if: matrix.os == 'ubuntu-22.04' + run: | + echo "LIBCLANG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/lib" >> $GITHUB_ENV + echo "LLVM_VERSION=${{ steps.clang.outputs.version }}" >> $GITHUB_ENV + echo "LLVM_CONFIG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/bin/llvm-config" >> $GITHUB_ENV + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring + coverage: none + + - name: Build PHP extension + run: | + # Export PHP configuration for ext-php-rs + export PHP_CONFIG=$(which php-config) + + cargo build --release + + # Get PHP extension directory + EXT_DIR=$(php -r "echo ini_get('extension_dir');") + + # Find the built library - ext-php-rs names it differently on different platforms + if [[ "${{ matrix.os }}" == "macos-13" ]]; then + # On macOS, look for .dylib files + BUILT_LIB=$(find target/release -name "libcss_inline_php.dylib" -o -name "css_inline_php.dylib" | head -1) + if [[ -z "$BUILT_LIB" ]]; then + # Fallback: any .dylib file + BUILT_LIB=$(find target/release -name "*.dylib" | head -1) + fi + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + else + # On Linux, look for .so files + BUILT_LIB=$(find target/release -name "libcss_inline_php.so" -o -name "css_inline_php.so" | head -1) + if [[ -z "$BUILT_LIB" ]]; then + # Fallback: any .so file + BUILT_LIB=$(find target/release -name "*.so" | head -1) + fi + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + fi + + echo "Built library: $BUILT_LIB" + echo "Installed to: $EXT_DIR/css_inline.so" + + # Verify the file exists and has correct permissions + ls -la "$EXT_DIR/css_inline.so" + working-directory: ./bindings/php + shell: bash + + - name: Enable and verify extension + run: | + # Create ini file to load extension + if [[ "${{ matrix.os }}" == "macos-13" ]]; then + # On macOS, find the additional ini directory + PHP_INI_DIR=$(php -i | grep "Scan this dir for additional .ini files" | cut -d' ' -f9 | tr -d ' ') + if [[ -z "$PHP_INI_DIR" || "$PHP_INI_DIR" == "(none)" ]]; then + # If no scan dir, use the main php.ini location + PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9) + PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d + sudo mkdir -p "$PHP_INI_DIR" + fi + echo "extension=css_inline" | sudo tee "$PHP_INI_DIR/99-css_inline.ini" + else + echo "extension=css_inline" | sudo tee /etc/php/${{ matrix.php-version }}/cli/conf.d/99-css_inline.ini + fi + + # Verify extension is loaded + php -m | grep -i css_inline || ( + echo "Extension failed to load. Debugging info:" + echo "PHP Version:" + php -v + echo "Extension dir contents:" + ls -la $(php -r "echo ini_get('extension_dir');") + echo "PHP info grep for css_inline:" + php -i | grep -i css_inline || true + echo "Try loading directly:" + php -d "extension=$(php -r 'echo ini_get("extension_dir");')/css_inline.so" -m | grep -i css_inline || true + exit 1 + ) + shell: bash + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + working-directory: ./bindings/php + + - name: Run tests + run: composer test + working-directory: ./bindings/php + test-ruby: strategy: fail-fast: false diff --git a/bindings/php/.cargo/config.toml b/bindings/php/.cargo/config.toml new file mode 100644 index 00000000..d566dec3 --- /dev/null +++ b/bindings/php/.cargo/config.toml @@ -0,0 +1,12 @@ +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" +rustflags = ["-C", "link-arg=/FORCE"] diff --git a/bindings/php/.gitignore b/bindings/php/.gitignore new file mode 100644 index 00000000..559dc605 --- /dev/null +++ b/bindings/php/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ diff --git a/bindings/php/Cargo.toml b/bindings/php/Cargo.toml new file mode 100644 index 00000000..ddfdbcbb --- /dev/null +++ b/bindings/php/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "css_inline" +version = "0.15.0" +edition = "2024" +authors = ["Dmitry Dygalo "] + +[lib] +name = "css_inline_php" +crate-type = ["cdylib"] + +[dependencies] +ext-php-rs = "0.14" + +[dependencies.css-inline] +path = "../../css-inline" +version = "*" +default-features = false +features = ["http", "file", "stylesheet-cache"] diff --git a/bindings/php/README.md b/bindings/php/README.md new file mode 100644 index 00000000..ebf9b307 --- /dev/null +++ b/bindings/php/README.md @@ -0,0 +1,26 @@ +# css_inline + +[build status](https://github.com/Stranger6667/css-inline/actions/workflows/build.yml) +[codecov.io](https://app.codecov.io/github/Stranger6667/css-inline) +[gitter](https://gitter.im/Stranger6667/css-inline) + +`css_inline` is a high-performance library for inlining CSS into HTML 'style' attributes. + +## Performance + +This library uses components from Mozilla's Servo project for CSS parsing and matching. +Performance benchmarks show 3-9x faster execution than `tijsverkoyen/css-to-inline-styles`. + +The table below shows benchmark results comparing `css_inline` with `tijsverkoyen/css-to-inline-styles` on typical HTML documents: + +| | Size | `css_inline 0.15.0` | `tijsverkoyen/css-to-inline-styles 2.2.7` | Speedup | +|-------------------|---------|---------------------|-------------------------------------------|---------| +| Simple | 230 B | 5.99 µs | 28.06 µs | **4.68x** | +| Realistic email 1 | 8.58 KB | 102.25 µs | 313.31 µs | **3.06x** | +| Realistic email 2 | 4.3 KB | 71.98 µs | 655.43 µs | **9.10x** | +| GitHub Page† | 1.81 MB | 163.80 ms | 8.22 ms* | N/A | + +> † The GitHub page benchmark uses modern CSS that `tijsverkoyen/css-to-inline-styles` cannot process, resulting in skipped styles and an invalid comparison. + +Please refer to the `benchmarks/InlineBench.php` file to review the benchmark code. +The results displayed above were measured using stable `rustc 1.88` on PHP `8.4.10`. diff --git a/bindings/php/benchmarks/InlineBench.php b/bindings/php/benchmarks/InlineBench.php new file mode 100644 index 00000000..aad5eb00 --- /dev/null +++ b/bindings/php/benchmarks/InlineBench.php @@ -0,0 +1,47 @@ +cssToInlineStyles = new CssToInlineStyles(); + } + + /** + * @ParamProviders("provideBenchmarkCases") + */ + public function benchCssInline(array $params): void + { + \CssInline\inline($params['html']); + } + + /** + * @ParamProviders("provideBenchmarkCases") + */ + public function benchCssToInlineStyles(array $params): void + { + $this->cssToInlineStyles->convert($params['html']); + } + + + public function provideBenchmarkCases(): \Generator + { + $jsonPath = __DIR__ . '/../../../benchmarks/benchmarks.json'; + $json = file_get_contents($jsonPath); + $benchmarks = json_decode($json, true); + + foreach ($benchmarks as $benchmark) { + yield $benchmark['name'] => [ + 'html' => $benchmark['html'] + ]; + } + } +} diff --git a/bindings/php/composer.json b/bindings/php/composer.json new file mode 100644 index 00000000..4d7daea0 --- /dev/null +++ b/bindings/php/composer.json @@ -0,0 +1,28 @@ +{ + "name": "css-inline/php", + "description": "High-performance library for inlining CSS into HTML 'style' attributes", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.2", + "ext-css_inline": "*" + }, + "require-dev": { + "phpbench/phpbench": "^1.4", + "phpunit/phpunit": "^10.5", + "tijsverkoyen/css-to-inline-styles": "^2.3" + }, + "autoload-dev": { + "psr-4": { + "CssInline\\Tests\\": "tests/CssInlineTest" + } + }, + "scripts": { + "test": "phpunit", + "bench": "phpbench run --report=default --iterations=10 --revs=100" + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true + } +} diff --git a/bindings/php/phpbench.json b/bindings/php/phpbench.json new file mode 100644 index 00000000..967336e3 --- /dev/null +++ b/bindings/php/phpbench.json @@ -0,0 +1,7 @@ +{ + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.php_config": { + "extension": "target/release/libcss_inline_php.so" + } +} diff --git a/bindings/php/phpunit.xml b/bindings/php/phpunit.xml new file mode 100644 index 00000000..4f1ea7fe --- /dev/null +++ b/bindings/php/phpunit.xml @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/bindings/php/src/lib.rs b/bindings/php/src/lib.rs new file mode 100644 index 00000000..51494889 --- /dev/null +++ b/bindings/php/src/lib.rs @@ -0,0 +1,87 @@ +use std::fmt::Display; + +use ext_php_rs::{exception::PhpException, prelude::*, zend::ce}; + +#[php_class] +#[php(name = "CssInline\\InlineError")] +#[php(extends(ce = ce::exception, stub = "\\Exception"))] +#[derive(Default)] +pub struct InlineError; + +fn from_error(error: E) -> PhpException { + PhpException::from_class::(error.to_string()) +} + +#[php_class] +#[php(name = "CssInline\\CssInliner")] +pub struct CssInliner { + inner: css_inline::CSSInliner<'static>, +} + +#[php_impl] +impl CssInliner { + #[php(defaults( + inline_style_tags = true, + keep_style_tags = false, + keep_link_tags = false, + load_remote_stylesheets = true, + ))] + #[php(optional = inline_style_tags)] + pub fn __construct( + inline_style_tags: bool, + keep_style_tags: bool, + keep_link_tags: bool, + load_remote_stylesheets: bool, + base_url: Option, + extra_css: Option, + ) -> PhpResult { + let base_url = if let Some(url) = base_url { + Some(css_inline::Url::parse(&url).map_err(from_error)?) + } else { + None + }; + + let options = css_inline::InlineOptions { + inline_style_tags, + keep_style_tags, + keep_link_tags, + base_url, + load_remote_stylesheets, + extra_css: extra_css.map(Into::into), + ..Default::default() + }; + + Ok(CssInliner { + inner: css_inline::CSSInliner::new(options), + }) + } + + pub fn inline(&self, html: &str) -> PhpResult { + self.inner.inline(html).map_err(from_error) + } + + pub fn inline_fragment(&self, html: &str, css: &str) -> PhpResult { + self.inner.inline_fragment(html, css).map_err(from_error) + } +} + +#[php_function] +#[php(name = "CssInline\\inline")] +pub fn inline(html: &str) -> PhpResult { + css_inline::inline(html).map_err(from_error) +} + +#[php_function] +#[php(name = "CssInline\\inline_fragment")] +pub fn inline_fragment(fragment: &str, css: &str) -> PhpResult { + css_inline::inline_fragment(fragment, css).map_err(from_error) +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module + .class::() + .class::() + .function(wrap_function!(inline)) + .function(wrap_function!(inline_fragment)) +} diff --git a/bindings/php/stubs/css_inline.php b/bindings/php/stubs/css_inline.php new file mode 100644 index 00000000..ac498cdb --- /dev/null +++ b/bindings/php/stubs/css_inline.php @@ -0,0 +1,21 @@ +h1 { color: blue; }

Hello

'; + $result = CssInline\inline($html); + $this->assertStringContainsString('style="color: blue;"', $result); + $this->assertStringNotContainsString('

Test

'; + $result = $inliner->inline($html); + + $this->assertStringNotContainsString('style="color: blue;"', $result); + $this->assertStringContainsString(' +

Title

+

Paragraph

+ HTML; + + $result = CssInline\inline($html); + + $this->assertStringContainsString('color: blue', $result); + $this->assertStringContainsString('font-size: 20px', $result); + $this->assertStringContainsString('margin: 10px', $result); + } + + public function testPreserveExistingInlineStyles(): void + { + $html = '

Hello

'; + $result = CssInline\inline($html); + + // Should merge styles + $this->assertStringContainsString('color: blue', $result); + $this->assertStringContainsString('font-size: 24px', $result); + } +} From bcf64b0c38b4f302b1e8e7699ad5d0ded4697943 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sat, 5 Jul 2025 16:20:23 +0200 Subject: [PATCH 2/7] wip Signed-off-by: Dmitry Dygalo --- .github/workflows/build.yml | 65 ++++--------------------- bindings/php/benchmarks/InlineBench.php | 2 + 2 files changed, 11 insertions(+), 56 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a35ca0c3..c350ad48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -541,18 +541,11 @@ jobs: name: PHP ${{ matrix.php-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} - env: - CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - name: Cache cargo dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: bindings/php - - name: Cache LLVM and Clang id: cache-llvm uses: actions/cache@v4 @@ -585,71 +578,31 @@ jobs: - name: Build PHP extension run: | - # Export PHP configuration for ext-php-rs export PHP_CONFIG=$(which php-config) - + cargo build --release - - # Get PHP extension directory + EXT_DIR=$(php -r "echo ini_get('extension_dir');") - - # Find the built library - ext-php-rs names it differently on different platforms + if [[ "${{ matrix.os }}" == "macos-13" ]]; then - # On macOS, look for .dylib files - BUILT_LIB=$(find target/release -name "libcss_inline_php.dylib" -o -name "css_inline_php.dylib" | head -1) - if [[ -z "$BUILT_LIB" ]]; then - # Fallback: any .dylib file - BUILT_LIB=$(find target/release -name "*.dylib" | head -1) - fi - sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + BUILT_LIB=$(find target/release -name "*.dylib" | head -1) else - # On Linux, look for .so files - BUILT_LIB=$(find target/release -name "libcss_inline_php.so" -o -name "css_inline_php.so" | head -1) - if [[ -z "$BUILT_LIB" ]]; then - # Fallback: any .so file - BUILT_LIB=$(find target/release -name "*.so" | head -1) - fi - sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + BUILT_LIB=$(find target/release -name "*.so" | head -1) fi - - echo "Built library: $BUILT_LIB" - echo "Installed to: $EXT_DIR/css_inline.so" - - # Verify the file exists and has correct permissions - ls -la "$EXT_DIR/css_inline.so" + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" working-directory: ./bindings/php shell: bash - name: Enable and verify extension run: | - # Create ini file to load extension if [[ "${{ matrix.os }}" == "macos-13" ]]; then - # On macOS, find the additional ini directory - PHP_INI_DIR=$(php -i | grep "Scan this dir for additional .ini files" | cut -d' ' -f9 | tr -d ' ') - if [[ -z "$PHP_INI_DIR" || "$PHP_INI_DIR" == "(none)" ]]; then - # If no scan dir, use the main php.ini location - PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9) - PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d - sudo mkdir -p "$PHP_INI_DIR" - fi + PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9) + PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d + sudo mkdir -p "$PHP_INI_DIR" echo "extension=css_inline" | sudo tee "$PHP_INI_DIR/99-css_inline.ini" else echo "extension=css_inline" | sudo tee /etc/php/${{ matrix.php-version }}/cli/conf.d/99-css_inline.ini fi - - # Verify extension is loaded - php -m | grep -i css_inline || ( - echo "Extension failed to load. Debugging info:" - echo "PHP Version:" - php -v - echo "Extension dir contents:" - ls -la $(php -r "echo ini_get('extension_dir');") - echo "PHP info grep for css_inline:" - php -i | grep -i css_inline || true - echo "Try loading directly:" - php -d "extension=$(php -r 'echo ini_get("extension_dir");')/css_inline.so" -m | grep -i css_inline || true - exit 1 - ) shell: bash - name: Install dependencies diff --git a/bindings/php/benchmarks/InlineBench.php b/bindings/php/benchmarks/InlineBench.php index aad5eb00..9bf53f62 100644 --- a/bindings/php/benchmarks/InlineBench.php +++ b/bindings/php/benchmarks/InlineBench.php @@ -6,6 +6,8 @@ use CssInline; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; +ini_set('pcre.backtrack_limit', 1000000); + class InlineBench { private CssToInlineStyles $cssToInlineStyles; From c0d031aa867efad317a7bc4c3dbe21d696f46c6f Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sat, 5 Jul 2025 16:27:37 +0200 Subject: [PATCH 3/7] wip Signed-off-by: Dmitry Dygalo --- .github/workflows/build.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c350ad48..e4ef7af2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -585,20 +585,29 @@ jobs: EXT_DIR=$(php -r "echo ini_get('extension_dir');") if [[ "${{ matrix.os }}" == "macos-13" ]]; then - BUILT_LIB=$(find target/release -name "*.dylib" | head -1) + BUILT_LIB=$(find target/release -name "libcss_inline_php.dylib" -o -name "css_inline_php.dylib" | head -1) + if [[ -z "$BUILT_LIB" ]]; then + BUILT_LIB=$(find target/release -name "*.dylib" | head -1) + fi + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" else BUILT_LIB=$(find target/release -name "*.so" | head -1) + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" fi - sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" working-directory: ./bindings/php shell: bash - name: Enable and verify extension run: | if [[ "${{ matrix.os }}" == "macos-13" ]]; then - PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9) - PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d - sudo mkdir -p "$PHP_INI_DIR" + # On macOS, find the additional ini directory + PHP_INI_DIR=$(php -i | grep "Scan this dir for additional .ini files" | cut -d' ' -f9 | tr -d ' ') + if [[ -z "$PHP_INI_DIR" || "$PHP_INI_DIR" == "(none)" ]]; then + # If no scan dir, use the main php.ini location + PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9) + PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d + sudo mkdir -p "$PHP_INI_DIR" + fi echo "extension=css_inline" | sudo tee "$PHP_INI_DIR/99-css_inline.ini" else echo "extension=css_inline" | sudo tee /etc/php/${{ matrix.php-version }}/cli/conf.d/99-css_inline.ini From 439f757ec662df5da0a2cd09967e1127acab05d4 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sat, 5 Jul 2025 16:35:38 +0200 Subject: [PATCH 4/7] wip Signed-off-by: Dmitry Dygalo --- .github/workflows/build.yml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4ef7af2..93df4e02 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -585,29 +585,18 @@ jobs: EXT_DIR=$(php -r "echo ini_get('extension_dir');") if [[ "${{ matrix.os }}" == "macos-13" ]]; then - BUILT_LIB=$(find target/release -name "libcss_inline_php.dylib" -o -name "css_inline_php.dylib" | head -1) - if [[ -z "$BUILT_LIB" ]]; then - BUILT_LIB=$(find target/release -name "*.dylib" | head -1) - fi - sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + BUILT_LIB=$(find target/release -name "*.dylib" | head -1) else BUILT_LIB=$(find target/release -name "*.so" | head -1) - sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" fi + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" working-directory: ./bindings/php shell: bash - name: Enable and verify extension run: | if [[ "${{ matrix.os }}" == "macos-13" ]]; then - # On macOS, find the additional ini directory PHP_INI_DIR=$(php -i | grep "Scan this dir for additional .ini files" | cut -d' ' -f9 | tr -d ' ') - if [[ -z "$PHP_INI_DIR" || "$PHP_INI_DIR" == "(none)" ]]; then - # If no scan dir, use the main php.ini location - PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9) - PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d - sudo mkdir -p "$PHP_INI_DIR" - fi echo "extension=css_inline" | sudo tee "$PHP_INI_DIR/99-css_inline.ini" else echo "extension=css_inline" | sudo tee /etc/php/${{ matrix.php-version }}/cli/conf.d/99-css_inline.ini From 624ab027a3cf868aa0382b3a4b9b691669f68e60 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sat, 5 Jul 2025 17:03:20 +0200 Subject: [PATCH 5/7] wip Signed-off-by: Dmitry Dygalo --- .github/workflows/build.yml | 8 ++++++-- bindings/php/benchmarks/InlineBench.php | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 93df4e02..8fb6cbf8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -585,11 +585,15 @@ jobs: EXT_DIR=$(php -r "echo ini_get('extension_dir');") if [[ "${{ matrix.os }}" == "macos-13" ]]; then - BUILT_LIB=$(find target/release -name "*.dylib" | head -1) + BUILT_LIB=$(find target/release -name "libcss_inline_php.dylib" -o -name "css_inline_php.dylib" | head -1) + if [[ -z "$BUILT_LIB" ]]; then + BUILT_LIB=$(find target/release -name "*.dylib" | head -1) + fi + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" else BUILT_LIB=$(find target/release -name "*.so" | head -1) + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" fi - sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" working-directory: ./bindings/php shell: bash diff --git a/bindings/php/benchmarks/InlineBench.php b/bindings/php/benchmarks/InlineBench.php index 9bf53f62..a52c1c26 100644 --- a/bindings/php/benchmarks/InlineBench.php +++ b/bindings/php/benchmarks/InlineBench.php @@ -6,7 +6,6 @@ use CssInline; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; -ini_set('pcre.backtrack_limit', 1000000); class InlineBench { @@ -15,6 +14,8 @@ class InlineBench public function __construct() { $this->cssToInlineStyles = new CssToInlineStyles(); + ini_set('pcre.backtrack_limit', '10000000'); + ini_set('pcre.recursion_limit', '10000000'); } /** From 46814f6a20af59a6fe07a6b0ec8034d43a87f3e8 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sat, 5 Jul 2025 20:26:42 +0200 Subject: [PATCH 6/7] wip Signed-off-by: Dmitry Dygalo --- bindings/php/README.md | 18 ++++++++---------- bindings/php/benchmarks/InlineBench.php | 11 ++++++++++- bindings/php/composer.json | 4 +++- bindings/php/phpbench.json | 3 ++- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/bindings/php/README.md b/bindings/php/README.md index ebf9b307..672e941c 100644 --- a/bindings/php/README.md +++ b/bindings/php/README.md @@ -9,18 +9,16 @@ ## Performance This library uses components from Mozilla's Servo project for CSS parsing and matching. -Performance benchmarks show 3-9x faster execution than `tijsverkoyen/css-to-inline-styles`. +Performance benchmarks show significant speed improvements over other popular PHP CSS inlining libraries. -The table below shows benchmark results comparing `css_inline` with `tijsverkoyen/css-to-inline-styles` on typical HTML documents: +| | Size | `css_inline 0.15.0` | `css-to-inline-styles 2.3.0` | `emogrifier 7.3.0` | +|-------------------|---------|---------------------|------------------------------|-------------------------| +| Simple | 230 B | 5.99 µs | 28.06 µs (**4.68x**) | 137.85 µs (**23.01x**) | +| Realistic email 1 | 8.58 KB | 102.25 µs | 313.31 µs (**3.06x**) | 637.75 µs (**6.24x**) | +| Realistic email 2 | 4.3 KB | 71.98 µs | 655.43 µs (**9.10x**) | 2.32 ms (**32.21x**) | +| GitHub Page† | 1.81 MB | 163.80 ms | ERROR | ERROR | -| | Size | `css_inline 0.15.0` | `tijsverkoyen/css-to-inline-styles 2.2.7` | Speedup | -|-------------------|---------|---------------------|-------------------------------------------|---------| -| Simple | 230 B | 5.99 µs | 28.06 µs | **4.68x** | -| Realistic email 1 | 8.58 KB | 102.25 µs | 313.31 µs | **3.06x** | -| Realistic email 2 | 4.3 KB | 71.98 µs | 655.43 µs | **9.10x** | -| GitHub Page† | 1.81 MB | 163.80 ms | 8.22 ms* | N/A | - -> † The GitHub page benchmark uses modern CSS that `tijsverkoyen/css-to-inline-styles` cannot process, resulting in skipped styles and an invalid comparison. +† The GitHub page benchmark contains complex modern CSS that neither `css-to-inline-styles` nor `emogrifier` can process and didn't finish a single iteration in >10 minutes. Please refer to the `benchmarks/InlineBench.php` file to review the benchmark code. The results displayed above were measured using stable `rustc 1.88` on PHP `8.4.10`. diff --git a/bindings/php/benchmarks/InlineBench.php b/bindings/php/benchmarks/InlineBench.php index a52c1c26..b4d18f0f 100644 --- a/bindings/php/benchmarks/InlineBench.php +++ b/bindings/php/benchmarks/InlineBench.php @@ -5,7 +5,7 @@ use PhpBench\Benchmark\Metadata\Annotations\ParamProviders; use CssInline; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; - +use Pelago\Emogrifier\CssInliner; class InlineBench { @@ -16,6 +16,7 @@ public function __construct() $this->cssToInlineStyles = new CssToInlineStyles(); ini_set('pcre.backtrack_limit', '10000000'); ini_set('pcre.recursion_limit', '10000000'); + ini_set('memory_limit', '2048M'); } /** @@ -34,6 +35,14 @@ public function benchCssToInlineStyles(array $params): void $this->cssToInlineStyles->convert($params['html']); } + /** + * @ParamProviders("provideBenchmarkCases") + */ + public function benchEmogrifier(array $params): void + { + CssInliner::fromHtml($params['html'])->inlineCss()->render(); + } + public function provideBenchmarkCases(): \Generator { diff --git a/bindings/php/composer.json b/bindings/php/composer.json index 4d7daea0..9c3337e1 100644 --- a/bindings/php/composer.json +++ b/bindings/php/composer.json @@ -8,6 +8,7 @@ "ext-css_inline": "*" }, "require-dev": { + "pelago/emogrifier": "^7.3", "phpbench/phpbench": "^1.4", "phpunit/phpunit": "^10.5", "tijsverkoyen/css-to-inline-styles": "^2.3" @@ -23,6 +24,7 @@ }, "config": { "sort-packages": true, - "optimize-autoloader": true + "optimize-autoloader": true, + "process-timeout": 0 } } diff --git a/bindings/php/phpbench.json b/bindings/php/phpbench.json index 967336e3..fa2498c9 100644 --- a/bindings/php/phpbench.json +++ b/bindings/php/phpbench.json @@ -3,5 +3,6 @@ "runner.path": "benchmarks", "runner.php_config": { "extension": "target/release/libcss_inline_php.so" - } + }, + "runner.timeout": 3600 } From d5c1b4b05f3b7b609562e4936b6667ef085390cd Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sun, 6 Jul 2025 14:50:32 +0200 Subject: [PATCH 7/7] wip Signed-off-by: Dmitry Dygalo --- bindings/php/Cargo.toml | 2 +- bindings/php/src/lib.rs | 33 +++++++++++++++++++++++++++- bindings/php/tests/CssInlineTest.php | 1 - 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/bindings/php/Cargo.toml b/bindings/php/Cargo.toml index ddfdbcbb..f02d18c8 100644 --- a/bindings/php/Cargo.toml +++ b/bindings/php/Cargo.toml @@ -9,7 +9,7 @@ name = "css_inline_php" crate-type = ["cdylib"] [dependencies] -ext-php-rs = "0.14" +ext-php-rs = "0.14.1" [dependencies.css-inline] path = "../../css-inline" diff --git a/bindings/php/src/lib.rs b/bindings/php/src/lib.rs index 51494889..d4567070 100644 --- a/bindings/php/src/lib.rs +++ b/bindings/php/src/lib.rs @@ -1,4 +1,4 @@ -use std::fmt::Display; +use std::{fmt::Display, num::NonZeroUsize, sync::Mutex}; use ext_php_rs::{exception::PhpException, prelude::*, zend::ce}; @@ -12,6 +12,22 @@ fn from_error(error: E) -> PhpException { PhpException::from_class::(error.to_string()) } +#[php_class] +#[php(name = "CssInline\\StylesheetCache")] +pub struct StylesheetCache { + size: NonZeroUsize, +} + +#[php_impl] +impl StylesheetCache { + pub fn __construct(size: usize) -> PhpResult { + let size = NonZeroUsize::new(size).ok_or_else(|| { + PhpException::default("Cache size must be an integer greater than zero".to_string()) + })?; + Ok(StylesheetCache { size }) + } +} + #[php_class] #[php(name = "CssInline\\CssInliner")] pub struct CssInliner { @@ -25,6 +41,10 @@ impl CssInliner { keep_style_tags = false, keep_link_tags = false, load_remote_stylesheets = true, + base_url = None, + extra_css = None, + preallocate_node_capacity = 32_usize, + cache = None, ))] #[php(optional = inline_style_tags)] pub fn __construct( @@ -34,6 +54,8 @@ impl CssInliner { load_remote_stylesheets: bool, base_url: Option, extra_css: Option, + preallocate_node_capacity: usize, + cache: Option<&StylesheetCache>, ) -> PhpResult { let base_url = if let Some(url) = base_url { Some(css_inline::Url::parse(&url).map_err(from_error)?) @@ -41,6 +63,12 @@ impl CssInliner { None }; + let cache = if let Some(cache) = cache { + Some(Mutex::new(css_inline::StylesheetCache::new(cache.size))) + } else { + None + }; + let options = css_inline::InlineOptions { inline_style_tags, keep_style_tags, @@ -48,6 +76,8 @@ impl CssInliner { base_url, load_remote_stylesheets, extra_css: extra_css.map(Into::into), + preallocate_node_capacity, + cache, ..Default::default() }; @@ -81,6 +111,7 @@ pub fn inline_fragment(fragment: &str, css: &str) -> PhpResult { pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { module .class::() + .class::() .class::() .function(wrap_function!(inline)) .function(wrap_function!(inline_fragment)) diff --git a/bindings/php/tests/CssInlineTest.php b/bindings/php/tests/CssInlineTest.php index a9779dda..27e79066 100644 --- a/bindings/php/tests/CssInlineTest.php +++ b/bindings/php/tests/CssInlineTest.php @@ -26,7 +26,6 @@ public function testInlineFragment(): void public function testCSSInlinerWithExtraCss(): void { $inliner = new CSSInliner( - base_url: null, extra_css: 'p { color: green; }' );