Skip to content

Commit 5656043

Browse files
committed
feat: PHP bindings
Signed-off-by: Dmitry Dygalo <[email protected]>
1 parent dcd2835 commit 5656043

File tree

12 files changed

+481
-0
lines changed

12 files changed

+481
-0
lines changed

.github/workflows/build.yml

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,135 @@ jobs:
531531
DEFAULT_CROSS_BUILD_ENV_URL: "https://github.com/pyodide/pyodide/releases/download/0.28.0a3/xbuildenv-0.28.0a3.tar.bz2"
532532
RUSTFLAGS: "-C link-arg=-sSIDE_MODULE=2 -Z link-native-libraries=no -Z emscripten-wasm-eh"
533533

534+
test-php:
535+
strategy:
536+
fail-fast: false
537+
matrix:
538+
os: [ubuntu-22.04, macos-13]
539+
php-version: ["8.2", "8.3", "8.4"]
540+
clang: ["20"]
541+
542+
name: PHP ${{ matrix.php-version }} on ${{ matrix.os }}
543+
runs-on: ${{ matrix.os }}
544+
env:
545+
CARGO_TERM_COLOR: always
546+
steps:
547+
- uses: actions/checkout@v4
548+
549+
- uses: dtolnay/rust-toolchain@stable
550+
551+
- name: Cache cargo dependencies
552+
uses: Swatinem/rust-cache@v2
553+
with:
554+
workspaces: bindings/php
555+
556+
- name: Cache LLVM and Clang
557+
id: cache-llvm
558+
uses: actions/cache@v4
559+
if: matrix.os == 'ubuntu-22.04'
560+
with:
561+
path: ${{ runner.temp }}/llvm-${{ matrix.clang }}
562+
key: ${{ matrix.os }}-llvm-${{ matrix.clang }}
563+
564+
- name: Setup LLVM & Clang
565+
id: clang
566+
uses: KyleMayes/install-llvm-action@v2
567+
if: matrix.os == 'ubuntu-22.04'
568+
with:
569+
version: ${{ matrix.clang }}
570+
directory: ${{ runner.temp }}/llvm-${{ matrix.clang }}
571+
cached: ${{ steps.cache-llvm.outputs.cache-hit }}
572+
573+
- name: Configure Clang
574+
if: matrix.os == 'ubuntu-22.04'
575+
run: |
576+
echo "LIBCLANG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/lib" >> $GITHUB_ENV
577+
echo "LLVM_VERSION=${{ steps.clang.outputs.version }}" >> $GITHUB_ENV
578+
echo "LLVM_CONFIG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/bin/llvm-config" >> $GITHUB_ENV
579+
580+
- uses: shivammathur/setup-php@v2
581+
with:
582+
php-version: ${{ matrix.php-version }}
583+
extensions: mbstring
584+
coverage: none
585+
586+
- name: Build PHP extension
587+
run: |
588+
# Export PHP configuration for ext-php-rs
589+
export PHP_CONFIG=$(which php-config)
590+
591+
cargo build --release
592+
593+
# Get PHP extension directory
594+
EXT_DIR=$(php -r "echo ini_get('extension_dir');")
595+
596+
# Find the built library - ext-php-rs names it differently on different platforms
597+
if [[ "${{ matrix.os }}" == "macos-13" ]]; then
598+
# On macOS, look for .dylib files
599+
BUILT_LIB=$(find target/release -name "libcss_inline_php.dylib" -o -name "css_inline_php.dylib" | head -1)
600+
if [[ -z "$BUILT_LIB" ]]; then
601+
# Fallback: any .dylib file
602+
BUILT_LIB=$(find target/release -name "*.dylib" | head -1)
603+
fi
604+
sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so"
605+
else
606+
# On Linux, look for .so files
607+
BUILT_LIB=$(find target/release -name "libcss_inline_php.so" -o -name "css_inline_php.so" | head -1)
608+
if [[ -z "$BUILT_LIB" ]]; then
609+
# Fallback: any .so file
610+
BUILT_LIB=$(find target/release -name "*.so" | head -1)
611+
fi
612+
sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so"
613+
fi
614+
615+
echo "Built library: $BUILT_LIB"
616+
echo "Installed to: $EXT_DIR/css_inline.so"
617+
618+
# Verify the file exists and has correct permissions
619+
ls -la "$EXT_DIR/css_inline.so"
620+
working-directory: ./bindings/php
621+
shell: bash
622+
623+
- name: Enable and verify extension
624+
run: |
625+
# Create ini file to load extension
626+
if [[ "${{ matrix.os }}" == "macos-13" ]]; then
627+
# On macOS, find the additional ini directory
628+
PHP_INI_DIR=$(php -i | grep "Scan this dir for additional .ini files" | cut -d' ' -f9 | tr -d ' ')
629+
if [[ -z "$PHP_INI_DIR" || "$PHP_INI_DIR" == "(none)" ]]; then
630+
# If no scan dir, use the main php.ini location
631+
PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9)
632+
PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d
633+
sudo mkdir -p "$PHP_INI_DIR"
634+
fi
635+
echo "extension=css_inline" | sudo tee "$PHP_INI_DIR/99-css_inline.ini"
636+
else
637+
echo "extension=css_inline" | sudo tee /etc/php/${{ matrix.php-version }}/cli/conf.d/99-css_inline.ini
638+
fi
639+
640+
# Verify extension is loaded
641+
php -m | grep -i css_inline || (
642+
echo "Extension failed to load. Debugging info:"
643+
echo "PHP Version:"
644+
php -v
645+
echo "Extension dir contents:"
646+
ls -la $(php -r "echo ini_get('extension_dir');")
647+
echo "PHP info grep for css_inline:"
648+
php -i | grep -i css_inline || true
649+
echo "Try loading directly:"
650+
php -d "extension=$(php -r 'echo ini_get("extension_dir");')/css_inline.so" -m | grep -i css_inline || true
651+
exit 1
652+
)
653+
shell: bash
654+
655+
- name: Install dependencies
656+
run: composer install --no-interaction --prefer-dist
657+
working-directory: ./bindings/php
658+
659+
- name: Run tests
660+
run: composer test
661+
working-directory: ./bindings/php
662+
534663
test-ruby:
535664
strategy:
536665
fail-fast: false

bindings/php/.cargo/config.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[target.x86_64-unknown-linux-gnu]
2+
rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"]
3+
4+
[target.x86_64-apple-darwin]
5+
rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"]
6+
7+
[target.aarch64-apple-darwin]
8+
rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"]
9+
10+
[target.x86_64-pc-windows-msvc]
11+
linker = "rust-lld"
12+
rustflags = ["-C", "link-arg=/FORCE"]

bindings/php/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/vendor/
2+
/composer.lock
3+
/.phpunit.cache/

bindings/php/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "css_inline"
3+
version = "0.15.0"
4+
edition = "2024"
5+
authors = ["Dmitry Dygalo <[email protected]>"]
6+
7+
[lib]
8+
name = "css_inline_php"
9+
crate-type = ["cdylib"]
10+
11+
[dependencies]
12+
ext-php-rs = "0.14"
13+
14+
[dependencies.css-inline]
15+
path = "../../css-inline"
16+
version = "*"
17+
default-features = false
18+
features = ["http", "file", "stylesheet-cache"]

bindings/php/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# css_inline
2+
3+
[<img alt="build status" src="https://img.shields.io/github/actions/workflow/status/Stranger6667/css-inline/build.yml?style=flat-square&labelColor=555555&logo=github" height="20">](https://github.com/Stranger6667/css-inline/actions/workflows/build.yml)
4+
[<img alt="codecov.io" src="https://img.shields.io/codecov/c/gh/Stranger6667/css-inline?logo=codecov&style=flat-square&token=tOzvV4kDY0" height="20">](https://app.codecov.io/github/Stranger6667/css-inline)
5+
[<img alt="gitter" src="https://img.shields.io/gitter/room/Stranger6667/css-inline?style=flat-square" height="20">](https://gitter.im/Stranger6667/css-inline)
6+
7+
`css_inline` is a high-performance library for inlining CSS into HTML 'style' attributes.
8+
9+
## Performance
10+
11+
This library uses components from Mozilla's Servo project for CSS parsing and matching.
12+
Performance benchmarks show 3-9x faster execution than `tijsverkoyen/css-to-inline-styles`.
13+
14+
The table below shows benchmark results comparing `css_inline` with `tijsverkoyen/css-to-inline-styles` on typical HTML documents:
15+
16+
| | Size | `css_inline 0.15.0` | `tijsverkoyen/css-to-inline-styles 2.2.7` | Speedup |
17+
|-------------------|---------|---------------------|-------------------------------------------|---------|
18+
| Simple | 230 B | 5.99 µs | 28.06 µs | **4.68x** |
19+
| Realistic email 1 | 8.58 KB | 102.25 µs | 313.31 µs | **3.06x** |
20+
| Realistic email 2 | 4.3 KB | 71.98 µs | 655.43 µs | **9.10x** |
21+
| GitHub Page† | 1.81 MB | 163.80 ms | 8.22 ms* | N/A |
22+
23+
> † The GitHub page benchmark uses modern CSS that `tijsverkoyen/css-to-inline-styles` cannot process, resulting in skipped styles and an invalid comparison.
24+
25+
Please refer to the `benchmarks/InlineBench.php` file to review the benchmark code.
26+
The results displayed above were measured using stable `rustc 1.88` on PHP `8.4.10`.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace CssInline\Benchmarks;
4+
5+
use PhpBench\Benchmark\Metadata\Annotations\ParamProviders;
6+
use CssInline;
7+
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
8+
9+
class InlineBench
10+
{
11+
private CssToInlineStyles $cssToInlineStyles;
12+
13+
public function __construct()
14+
{
15+
$this->cssToInlineStyles = new CssToInlineStyles();
16+
}
17+
18+
/**
19+
* @ParamProviders("provideBenchmarkCases")
20+
*/
21+
public function benchCssInline(array $params): void
22+
{
23+
\CssInline\inline($params['html']);
24+
}
25+
26+
/**
27+
* @ParamProviders("provideBenchmarkCases")
28+
*/
29+
public function benchCssToInlineStyles(array $params): void
30+
{
31+
$this->cssToInlineStyles->convert($params['html']);
32+
}
33+
34+
35+
public function provideBenchmarkCases(): \Generator
36+
{
37+
$jsonPath = __DIR__ . '/../../../benchmarks/benchmarks.json';
38+
$json = file_get_contents($jsonPath);
39+
$benchmarks = json_decode($json, true);
40+
41+
foreach ($benchmarks as $benchmark) {
42+
yield $benchmark['name'] => [
43+
'html' => $benchmark['html']
44+
];
45+
}
46+
}
47+
}

bindings/php/composer.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "css-inline/php",
3+
"description": "High-performance library for inlining CSS into HTML 'style' attributes",
4+
"type": "library",
5+
"license": "MIT",
6+
"require": {
7+
"php": ">=8.2",
8+
"ext-css_inline": "*"
9+
},
10+
"require-dev": {
11+
"phpbench/phpbench": "^1.4",
12+
"phpunit/phpunit": "^10.5",
13+
"tijsverkoyen/css-to-inline-styles": "^2.3"
14+
},
15+
"autoload-dev": {
16+
"psr-4": {
17+
"CssInline\\Tests\\": "tests/CssInlineTest"
18+
}
19+
},
20+
"scripts": {
21+
"test": "phpunit",
22+
"bench": "phpbench run --report=default --iterations=10 --revs=100"
23+
},
24+
"config": {
25+
"sort-packages": true,
26+
"optimize-autoloader": true
27+
}
28+
}

bindings/php/phpbench.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"runner.bootstrap": "vendor/autoload.php",
3+
"runner.path": "benchmarks",
4+
"runner.php_config": {
5+
"extension": "target/release/libcss_inline_php.so"
6+
}
7+
}

bindings/php/phpunit.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
4+
colors="true"
5+
stopOnFailure="false">
6+
<testsuites>
7+
<testsuite name="CssInline Test Suite">
8+
<directory>tests</directory>
9+
</testsuite>
10+
</testsuites>
11+
</phpunit>

bindings/php/src/lib.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
use std::fmt::Display;
2+
3+
use ext_php_rs::{exception::PhpException, prelude::*, zend::ce};
4+
5+
#[php_class]
6+
#[php(name = "CssInline\\InlineError")]
7+
#[php(extends(ce = ce::exception, stub = "\\Exception"))]
8+
#[derive(Default)]
9+
pub struct InlineError;
10+
11+
fn from_error<E: Display>(error: E) -> PhpException {
12+
PhpException::from_class::<InlineError>(error.to_string())
13+
}
14+
15+
#[php_class]
16+
#[php(name = "CssInline\\CssInliner")]
17+
pub struct CssInliner {
18+
inner: css_inline::CSSInliner<'static>,
19+
}
20+
21+
#[php_impl]
22+
impl CssInliner {
23+
#[php(defaults(
24+
inline_style_tags = true,
25+
keep_style_tags = false,
26+
keep_link_tags = false,
27+
load_remote_stylesheets = true,
28+
))]
29+
#[php(optional = inline_style_tags)]
30+
pub fn __construct(
31+
inline_style_tags: bool,
32+
keep_style_tags: bool,
33+
keep_link_tags: bool,
34+
load_remote_stylesheets: bool,
35+
base_url: Option<String>,
36+
extra_css: Option<String>,
37+
) -> PhpResult<CssInliner> {
38+
let base_url = if let Some(url) = base_url {
39+
Some(css_inline::Url::parse(&url).map_err(from_error)?)
40+
} else {
41+
None
42+
};
43+
44+
let options = css_inline::InlineOptions {
45+
inline_style_tags,
46+
keep_style_tags,
47+
keep_link_tags,
48+
base_url,
49+
load_remote_stylesheets,
50+
extra_css: extra_css.map(Into::into),
51+
..Default::default()
52+
};
53+
54+
Ok(CssInliner {
55+
inner: css_inline::CSSInliner::new(options),
56+
})
57+
}
58+
59+
pub fn inline(&self, html: &str) -> PhpResult<String> {
60+
self.inner.inline(html).map_err(from_error)
61+
}
62+
63+
pub fn inline_fragment(&self, html: &str, css: &str) -> PhpResult<String> {
64+
self.inner.inline_fragment(html, css).map_err(from_error)
65+
}
66+
}
67+
68+
#[php_function]
69+
#[php(name = "CssInline\\inline")]
70+
pub fn inline(html: &str) -> PhpResult<String> {
71+
css_inline::inline(html).map_err(from_error)
72+
}
73+
74+
#[php_function]
75+
#[php(name = "CssInline\\inline_fragment")]
76+
pub fn inline_fragment(fragment: &str, css: &str) -> PhpResult<String> {
77+
css_inline::inline_fragment(fragment, css).map_err(from_error)
78+
}
79+
80+
#[php_module]
81+
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
82+
module
83+
.class::<InlineError>()
84+
.class::<CssInliner>()
85+
.function(wrap_function!(inline))
86+
.function(wrap_function!(inline_fragment))
87+
}

0 commit comments

Comments
 (0)