Skip to content

Commit 5b42aa0

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

File tree

12 files changed

+440
-0
lines changed

12 files changed

+440
-0
lines changed

.github/workflows/build.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,94 @@ 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+
steps:
545+
- uses: actions/checkout@v4
546+
547+
- uses: dtolnay/rust-toolchain@stable
548+
549+
- name: Cache cargo dependencies
550+
uses: Swatinem/rust-cache@v2
551+
with:
552+
workspaces: bindings/php
553+
554+
- name: Cache LLVM and Clang
555+
id: cache-llvm
556+
uses: actions/cache@v4
557+
if: matrix.os == 'ubuntu-22.04'
558+
with:
559+
path: ${{ runner.temp }}/llvm-${{ matrix.clang }}
560+
key: ${{ matrix.os }}-llvm-${{ matrix.clang }}
561+
562+
- name: Setup LLVM & Clang
563+
id: clang
564+
uses: KyleMayes/install-llvm-action@v2
565+
if: matrix.os == 'ubuntu-22.04'
566+
with:
567+
version: ${{ matrix.clang }}
568+
directory: ${{ runner.temp }}/llvm-${{ matrix.clang }}
569+
cached: ${{ steps.cache-llvm.outputs.cache-hit }}
570+
571+
- name: Configure Clang
572+
if: matrix.os == 'ubuntu-22.04'
573+
run: |
574+
echo "LIBCLANG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/lib" >> $GITHUB_ENV
575+
echo "LLVM_VERSION=${{ steps.clang.outputs.version }}" >> $GITHUB_ENV
576+
echo "LLVM_CONFIG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/bin/llvm-config" >> $GITHUB_ENV
577+
578+
- uses: shivammathur/setup-php@v2
579+
with:
580+
php-version: ${{ matrix.php-version }}
581+
extensions: mbstring
582+
coverage: none
583+
584+
- name: Build PHP extension
585+
run: |
586+
export PHP_CONFIG=$(which php-config)
587+
588+
cargo build --release
589+
590+
EXT_DIR=$(php -r "echo ini_get('extension_dir');")
591+
592+
# Find the built library - ext-php-rs names it differently on different platforms
593+
if [[ "${{ matrix.os }}" == "macos-13" ]]; then
594+
BUILT_LIB=$(find target/release -name "*.dylib" | head -1)
595+
else
596+
BUILT_LIB=$(find target/release -name "*.so" | head -1)
597+
fi
598+
sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so"
599+
working-directory: ./bindings/php
600+
shell: bash
601+
602+
- name: Enable and verify extension
603+
run: |
604+
if [[ "${{ matrix.os }}" == "macos-13" ]]; then
605+
PHP_INI=$(php -i | grep "Loaded Configuration File" | cut -d' ' -f9)
606+
PHP_INI_DIR=$(dirname "$PHP_INI")/conf.d
607+
sudo mkdir -p "$PHP_INI_DIR"
608+
echo "extension=css_inline" | sudo tee "$PHP_INI_DIR/99-css_inline.ini"
609+
else
610+
echo "extension=css_inline" | sudo tee /etc/php/${{ matrix.php-version }}/cli/conf.d/99-css_inline.ini
611+
fi
612+
shell: bash
613+
614+
- name: Install dependencies
615+
run: composer install --no-interaction --prefer-dist
616+
working-directory: ./bindings/php
617+
618+
- name: Run tests
619+
run: composer test
620+
working-directory: ./bindings/php
621+
534622
test-ruby:
535623
strategy:
536624
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)