MejiroPDF 1.1.0
Rotation — and a fix for imported pages that were coming out sideways.
composer require 2bo-velocity/mejiropdf:^1.1This release is additive: nothing was removed, no signature changed
incompatibly, and a document that does not use the new arguments produces the
same bytes it did under 1.0.0. One behaviour does change, and it is a bug
fix — see Upgrade notes if you import existing PDFs.
Rotation
"Put this at an angle" turns out to be four different operations, depending on
what is being turned. 1.0.0 shipped only the first of them, and only for text.
One element
image() now takes a rotation, turning the picture about the centre of the
box you gave it — the same pivot textObject() has always used, so a caption
set at the same angle stays parallel to the picture.
$pdf->image('photo.jpg', x: 20, y: 60, width: 80, rotation: 45);The capability already existed inside the template renderer but was private to
it, so anyone writing PHP directly had to hand-assemble a cm matrix and pass
it to appendContentOps(). Both paths now share one implementation.
A whole group
rotated() turns everything a callback draws as one piece, about a pivot in
millimetres:
$pdf->rotated(-90.0, x: 25, y: 60, draw: function (Document $pdf): void {
$pdf->rect(25, 60, 180, 22, fill: '#1a3a7a');
$pdf->line(29, 76, 201, 76, color: '#FFFFFF');
$pdf->text(31, 72, 'Bound edge', color: '#FFFFFF');
});This is the one that was actually missing. What usually needs turning is not a
lone element but an assembly — a label, an obi band, a sideways table — whose
frame, rules and text have to keep their positions relative to each other.
Passing an angle to each of them separately cannot express that, because each
would spin about its own centre and the assembly would come apart. So there is
deliberately no rotation argument on rect(), line(), grid() or
drawBox(): rotated() is the answer for all of them.
Inside the group you write the coordinates you would have used unturned. Groups
nest, and the angles add up.
Three consequences follow from this being a single PDF transform, and all three
are handled explicitly rather than left to be discovered:
strictCanvasstands down inside the group. Only the pivot is checked, on
the way in. A turned frame's page coordinates no longer describe where the
marks land, so the check has nothing true to say about them.- Opening a page inside the group is refused with a
LogicException. A page
break would split the transform across two content streams and leave the first
one unbalanced. - Link areas do not turn. PDF pins an annotation's
/Rectto the page axes,
so alink()added inside a group stays an upright rectangle over turned text.
This cannot be fixed, so instead the engine says so once per link on the PSR-3
logger — the mismatch is otherwise invisible until someone tries to click it.
If the callback throws, the transform is still closed before the exception
leaves. A caller that catches it and carries on gets an upright page, not a
document where everything after the failure is at an angle.
The paper itself
addPage() now takes a rotation, written to the page dictionary's /Rotate:
$pdf->addPage(PageSize::a4(Orientation::LANDSCAPE), rotation: 90);This turns the sheet, not the marks — an instruction to the viewer and the
printer. It is how a document made of portrait pages carries one wide page,
laid out landscape and presented turned, without the reader's page width jumping
about.
It does not move the drawing origin. Every coordinate on such a page is
still measured on the unturned sheet exactly as on any other page. Values must
be multiples of 90; anything else raises InvalidArgumentException rather than
being rounded, because a viewer would ignore it and the page would quietly come
out upright. A page that does not ask to be turned carries no /Rotate entry at
all.
Fixed: imported pages ignored /Rotate
Scanners and office suites routinely store a landscape page as a portrait
MediaBox plus /Rotate 90 rather than as a landscape box. /Rotate is an
inheritable page attribute, and a producer may set it once on the page tree root
and leave every page silent about it.
1.0.0 resolved /MediaBox and /Resources through the page tree but never read
/Rotate at all, so such a source was imported on its side.
All three import paths now honour it:
| Method | What changes |
|---|---|
editTemplatePage() |
The new page takes the source's displayed size, so a rotated scan opens landscape with its content upright — and the millimetres you stamp in are the ones you measured on screen |
importPage() |
Native size and aspect ratio are the displayed ones, so asking for a width gives the height you expect |
importPageAsBackground() |
Fills the page in its display orientation |
Negative values (-90) are normalised. Values that are not multiples of 90 are
ignored rather than guessed at — leaving a broken producer's page as written
loses less than turning it by a guess.
The rotation is expressed as a placement matrix, not baked into the Form
XObject. The imported page stays byte-identical to its source, which is the
whole premise of the non-destructive overlay model.
All four paths were checked against an independent renderer: for /Rotate 270
(inherited), 90, and 45 (invalid), poppler's rendering of the source and this
engine's output are pixel-identical.
Upgrade notes
If you import PDFs that carry /Rotate, your output changes — for the
better. Pages that used to come out sideways now come out upright, and for
editTemplatePage() the page dimensions swap, which moves the millimetre
coordinate space you stamp into. If you built a workaround on top of the old
behaviour — rotating the imported page yourself, or swapping x/y in your field
coordinates — remove it.
Sources without /Rotate, which is most of them, are unaffected.
Everything else is byte-compatible. New arguments all default to no
rotation, and pages that are not turned emit no /Rotate.
One internal detail is visible if you diff raw PDF bytes: the cm that places an
imported page is now always written as six explicit values (1.0000 0.0000 0.0000 1.0000 …) instead of the shorthand 1 0 0 1 …, because one code path now
serves rotated and unrotated placement alike. The matrix is the same and so is
the rendering. Quarter turns also emit exact 0 and ±1 now, rather than the
1e-17 residue deg2rad() leaves behind.
Everything else
Unchanged from 1.0.0: constant-memory streaming, Japanese and Western
typesetting, font subsetting, GridTable, report blocks, compiled .mpb
templates, AES-128, PKCS#7 signatures and PDF/A-1b. See the
1.0.0 release notes
for what the engine does, and https://mejiropdf.com for the manual.
Requirements are unchanged: PHP 8.3+ (enforced at runtime), ext-mbstring,
psr/log, 64-bit.
Quality gates
PHPStan level 8 across src/, and a PHPUnit suite of 60 test classes (456
cases). Signatures and PDF/A output are verified in Docker against openssl /
pdfsig and veraPDF. The rotation behaviour above is additionally verified by
rasterising the output and comparing it against poppler. The 20 scripts in
examples/ are the working reference — examples/rotation.php
demonstrates all three rotation scopes side by side.
Contributing and support
Pull requests are not accepted — the dual license requires the copyright to
stay with one author. Bug reports are welcome and are used to improve the
library; the fix is then written by the author. There is no support
commitment. See CONTRIBUTING.md, and report security issues
privately to support@2bo-velocity.com.
日本語
MejiroPDF 1.1.0 は回転を追加し、取り込んだ PDF が横倒しになる不具合を修正した
リリースです。追加のみで、削除も非互換なシグネチャ変更もありません。新しい引数を使わない
限り、出力は 1.0.0 と同一です。ただし既存 PDF を取り込んでいる場合は挙動が変わります
(後述)。
回転 ― 回す対象の違う 4 つの操作
「角度をつける」は、何を回すかで別の操作になります。1.0.0 が持っていたのはこのうち
テキスト 1 ブロックだけでした。
① 単一要素: image() に rotation を追加しました。軸は与えたボックスの中心で、
textObject() と同じピボットです。この機能はテンプレート描画側に実装がありながら
private で、直接 PHP を書く利用者からは cm 行列を手で組んで appendContentOps() に
渡すしかありませんでした。両経路が同一実装を共有するようになりました。
$pdf->image('photo.jpg', x: 20, y: 60, width: 80, rotation: 45);② グループ: rotated() が、コールバックの描画をまとめて 1 つの変換で回します。
$pdf->rotated(-90.0, x: 25, y: 60, draw: function (Document $pdf): void {
$pdf->rect(25, 60, 180, 22, fill: '#1a3a7a');
$pdf->text(31, 72, 'Bound edge', color: '#FFFFFF');
});これが本当に欠けていたものです。 実務で回したいのは単独の要素ではなく、枠・罫線・
テキストからなる組み立て(ラベル、帯、横向きの表)であり、各要素に個別の角度引数を
配る方式ではそれぞれが自分の中心で回って組み立てが崩れます。ゆえに rect() /
line() / grid() / drawBox() に rotation 引数は意図的に追加していません。
グループ内の座標は「回していなければ書いたであろう座標」で、入れ子にでき、角度は加算
されます。
1 つの PDF 変換であることから来る 3 つの帰結を、発見に委ねず明示的に扱っています。
strictCanvasはグループ内で停止し、入口でピボットのみを検査します。回った枠では
ページ座標がインクの落ちる位置を表さず、検査が真を述べられないためです。- グループ内での改ページは
LogicExceptionで拒否します。変換が 2 つのコンテンツ
ストリームに割れ、前者が不均衡なまま残るためです。 - リンク注釈は回りません。 PDF は注釈の
/Rectをページ軸に固定するため原理的に
不可能です。修正できない以上、リンク 1 件につき 1 度だけ PSR-3 ロガーへ警告します
——クリックしてみるまで気づけないズレだからです。
コールバックが例外を投げても変換は必ず閉じます。catch して処理を続けた呼び出し側が、
以降の描画がすべて回ったままの文書を受け取ることはありません。
③ 用紙そのもの: addPage() に rotation を追加し、ページ辞書の /Rotate を書きます。
$pdf->addPage(PageSize::a4(Orientation::LANDSCAPE), rotation: 90);これはマークではなく紙を回す指示で、ビューアと印刷の双方に効きます。縦向きの文書へ
横長のページを 1 枚差し込むとき、読者から見たページ幅を揃えるための機能です。
描画原点は動きません——回転ページでも座標系は他のページと完全に同一です。90 の倍数
以外は InvalidArgumentException とします(丸めません。ビューアが値を無視して黙って縦の
まま出力される方が有害だからです)。回転を要求しないページに /Rotate は書きません。
修正: 取り込んだページが /Rotate を無視していた
スキャナやオフィススイートは、横長ページを「横長 MediaBox」ではなく**「縦長 MediaBox +
/Rotate 90」**として書き出すことが多くあります。/Rotate は継承可能なページ属性で、
生成器がページツリーのルートに 1 度だけ書き、各ページは何も持たないこともあります。
1.0.0 は /MediaBox と /Resources の継承は解決していましたが、/Rotate を一切
読んでいませんでした。そのため該当するソースは横倒しで取り込まれていました。
3 経路すべてが表示上のページ(/Rotate 適用後。90/270 では幅と高さが入れ替わる)を
扱うようになりました。とくに editTemplatePage() は表示サイズでページを開くため、
画面で測った mm がそのまま差込座標になります。負値(-90)は正規化し、90 の倍数で
ない値は無視します(壊れた生成器を推測で回すより、書かれたまま置く方が失うものが少ない
ため)。回転は Form XObject に焼き込まず配置行列で表現します——取り込んだページを
ソースとバイト単位で同一に保つことが、非破壊オーバーレイの前提だからです。
4 経路とも外部レンダラで検証しました。/Rotate 270(継承)/ 90 / 45(不正値)の
3 パターンで、poppler がソースを描画した結果と本エンジンの出力がピクセル単位で一致
します。
アップグレード時の注意
/Rotate を持つ PDF を取り込んでいる場合、出力が変わります(正しくなります)。
横倒しだったページが正しい向きになり、editTemplatePage() ではページの縦横が入れ替わる
ため、差込に使う mm 座標系が移動します。旧挙動を前提とした回避策(自分で回す、フィールド
座標の x/y を入れ替える等)を入れている場合は外してください。/Rotate を持たない
ソース(大半がこれです)は影響を受けません。
それ以外はバイト互換です。新しい引数はすべて既定で無回転、回転しないページに
/Rotate は出ません。生の PDF バイト列を比較する場合のみ気づく差として、取り込んだ
ページを配置する cm が 1 0 0 1 … の省略形ではなく常に 6 値(1.0000 0.0000 0.0000 1.0000 …)で書かれるようになりました。回転の有無で経路を分けないためで、行列も描画結果も
同一です。四分の一回転の cos/sin も厳密な 0 / ±1 を出すようになりました
(deg2rad() が残す 1e-17 を出力しません)。
その他
O(1) ストリーミング、日本語・欧文組版、フォントサブセット埋込、GridTable、帳票ブロック、
.mpb、AES-128、PKCS#7 署名、PDF/A-1b は 1.0.0 から変更ありません。動作要件も同じ
(PHP 8.3+ / ext-mbstring / psr/log / 64bit)です。
品質: src/ 全体に PHPStan level 8、PHPUnit 60 クラス 456 ケース。署名と PDF/A は
Docker 上で openssl / pdfsig / veraPDF により検証。回転はさらに出力をラスタライズして
poppler と突き合わせています。examples/ の 20 本が動作するリファレンスで、
examples/rotation.php が 3 つの回転スコープを 1 ファイルで
対比しています。
PR は受け付けていません(デュアルライセンスは著作権の単独保有が前提のため)。
不具合報告は歓迎しますが、サポートの約束はありません。詳細は
CONTRIBUTING.md、脆弱性は support@2bo-velocity.com へ非公開でご連絡
ください。