diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 962e6581..8fb6cbf8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -531,6 +531,90 @@ 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 }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - 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_CONFIG=$(which php-config) + + cargo build --release + + 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" + else + BUILT_LIB=$(find target/release -name "*.so" | head -1) + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + fi + working-directory: ./bindings/php + shell: bash + + - name: Enable and verify extension + run: | + if [[ "${{ matrix.os }}" == "macos-13" ]]; then + PHP_INI_DIR=$(php -i | grep "Scan this dir for additional .ini files" | cut -d' ' -f9 | tr -d ' ') + 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 + 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..f02d18c8 --- /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.1" + +[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..672e941c --- /dev/null +++ b/bindings/php/README.md @@ -0,0 +1,24 @@ +# 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 significant speed improvements over other popular PHP CSS inlining libraries. + +| | 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 | + +† 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 new file mode 100644 index 00000000..b4d18f0f --- /dev/null +++ b/bindings/php/benchmarks/InlineBench.php @@ -0,0 +1,59 @@ +cssToInlineStyles = new CssToInlineStyles(); + ini_set('pcre.backtrack_limit', '10000000'); + ini_set('pcre.recursion_limit', '10000000'); + ini_set('memory_limit', '2048M'); + } + + /** + * @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']); + } + + /** + * @ParamProviders("provideBenchmarkCases") + */ + public function benchEmogrifier(array $params): void + { + CssInliner::fromHtml($params['html'])->inlineCss()->render(); + } + + + 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..9c3337e1 --- /dev/null +++ b/bindings/php/composer.json @@ -0,0 +1,30 @@ +{ + "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": { + "pelago/emogrifier": "^7.3", + "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, + "process-timeout": 0 + } +} diff --git a/bindings/php/phpbench.json b/bindings/php/phpbench.json new file mode 100644 index 00000000..fa2498c9 --- /dev/null +++ b/bindings/php/phpbench.json @@ -0,0 +1,8 @@ +{ + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.php_config": { + "extension": "target/release/libcss_inline_php.so" + }, + "runner.timeout": 3600 +} 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..d4567070 --- /dev/null +++ b/bindings/php/src/lib.rs @@ -0,0 +1,118 @@ +use std::{fmt::Display, num::NonZeroUsize, sync::Mutex}; + +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\\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 { + 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, + base_url = None, + extra_css = None, + preallocate_node_capacity = 32_usize, + cache = None, + ))] + #[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, + 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)?) + } else { + 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, + keep_link_tags, + base_url, + load_remote_stylesheets, + extra_css: extra_css.map(Into::into), + preallocate_node_capacity, + cache, + ..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::() + .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); + } +}