Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ All notable changes to `dskripchenko/php-docx-phpword` are documented in
this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Requires `dskripchenko/php-docx` ^1.18.

### Added
- Sections both ways: a php-docx section per PhpWord section, with its page
size and orientation (a preset paper size, else the size as it is),
margins, header and footer offsets and start (`breakType`: new page,
continuous, even or odd page). Before, every section was folded into one.
- Headers and footers of every kind both ways: default, first-page and
even-page (`Header::FIRST`, `Header::EVEN`; even pages turn PhpWord's
`evenAndOddHeaders` on). Before, only the default ones were carried, and
PhpWord's headers of every kind were merged into one.
- Floating pictures both ways: php-docx's `Image::$position` ↔ PhpWord's
image style (`positioning`, `posHorizontal`/`posVertical`, their
`…Rel`, `marginLeft`/`marginTop`, `wrappingStyle`). Origins php-docx has
no case for are read as the nearest one.

### Fixed
- Pictures from a php-docx document reach PhpWord: they were handed over
as a `data:` URL, which PhpWord 1.x rejects as an invalid image, so any
document with a picture failed in `fromDocument()`. They go as bytes now.

### Changed
- `fromDocument()` sets each PhpWord section's page from the document's
page setup; before, PhpWord's default page was used.

## [1.0.0] — 2026-07-18

Initial release.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ The bridge maps what **both** models can express:
| headings 1–6 | charts, TOC, checkboxes, OLE |
| tables incl. `gridSpan` / `vMerge` | tracked changes, comments |
| bullet / numbered lists (nested) | footnotes/endnotes, OMML math |
| hyperlinks, images, line/page breaks | form fields |
| default headers/footers | first/even header variants, watermarks |
| paragraph alignment, spacing, indents | section/page geometry details |
| hyperlinks, images (in the line of text or floating: position, wrap, in front of or behind the text), line/page breaks | form fields |
| sections (page size and orientation, margins, start) with default, first-page and even-page headers/footers | watermarks |
| paragraph alignment, spacing, indents | section columns, page numbering, page borders |

If a document leans on the right-hand column, the bridge will not
pretend otherwise — content outside the set is dropped silently by
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
],
"require": {
"php": "^8.2",
"dskripchenko/php-docx": "^1.1",
"dskripchenko/php-docx": "^1.18",
"phpoffice/phpword": "^1.3"
},
"require-dev": {
Expand Down
6 changes: 3 additions & 3 deletions docs/de/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ Die Brücke überträgt, was **beide** Modelle ausdrücken können:
| Überschriften 1–6 | Diagramme, Inhaltsverzeichnis, Kontrollkästchen, OLE |
| Tabellen inkl. `gridSpan` / `vMerge` | Änderungsverfolgung, Kommentare |
| Aufzählungen und nummerierte Listen (auch verschachtelt) | Fuß- und Endnoten, OMML-Formeln |
| Hyperlinks, Bilder, Zeilen- und Seitenumbrüche | Formularfelder |
| Standard-Kopf-/Fußzeilen | eigene Varianten für erste und gerade Seiten, Wasserzeichen |
| Absatzausrichtung, Abstände, Einzüge | Feinheiten der Abschnitts- und Seitengeometrie |
| Hyperlinks, Bilder (im Text oder frei positioniert: Position, Umbruch, vor oder hinter dem Text), Zeilen- und Seitenumbrüche | Formularfelder |
| Abschnitte (Seitengröße und -ausrichtung, Ränder, Abschnittsbeginn) mit Standard-Kopf-/Fußzeilen sowie Varianten für erste und gerade Seiten | Wasserzeichen |
| Absatzausrichtung, Abstände, Einzüge | Spalten im Abschnitt, Seitennummerierung, Seitenrahmen |

Stützt sich ein Dokument auf die rechte Spalte, tut die Brücke nicht so, als
wäre alles in Ordnung: Inhalte außerhalb der gemeinsamen Menge werden bewusst
Expand Down
6 changes: 3 additions & 3 deletions docs/ru/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ php-docx.
| заголовки 1–6 | диаграммы, оглавление, чекбоксы, OLE |
| таблицы, включая `gridSpan` и `vMerge` | правки, комментарии |
| маркированные и нумерованные списки, в том числе вложенные | сноски и концевые сноски, формулы OMML |
| ссылки, изображения, разрывы строки и страницы | поля форм |
| колонтитулы по умолчанию | отдельные колонтитулы первой и чётных страниц, подложки |
| выравнивание, интервалы, отступы абзаца | тонкости геометрии разделов и страниц |
| ссылки, изображения (в строке и плавающие: позиция, обтекание, перед текстом или за ним), разрывы строки и страницы | поля форм |
| разделы (размер и ориентация страницы, поля, начало раздела) с колонтитулами по умолчанию, первой и чётных страниц | подложки |
| выравнивание, интервалы, отступы абзаца | колонки раздела, нумерация страниц, границы страницы |

Если документ держится на правом столбце, мост не станет делать вид, что всё в
порядке: содержимое вне общего множества сознательно отбрасывается молча. Для
Expand Down
6 changes: 3 additions & 3 deletions docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ $variables = PhpWordBridge::detectVariables($phpWordOrDocxBytes);
| 1–6 级标题 | 图表、目录、复选框、OLE |
| 表格,含 `gridSpan` / `vMerge` | 修订记录、批注 |
| 项目符号与编号列表(含嵌套) | 脚注/尾注、OMML 公式 |
| 超链接、图片、换行与分页符 | 表单域 |
| 默认页眉页脚 | 首页/偶数页的独立页眉、水印 |
| 段落对齐、间距、缩进 | 节与页面几何的细节 |
| 超链接、图片(行内或浮动:位置、环绕、置于文字上方或下方)、换行与分页符 | 表单域 |
| 节(页面尺寸与方向、页边距、起始方式),含默认、首页与偶数页页眉页脚 | 水印 |
| 段落对齐、间距、缩进 | 节内分栏、页码、页面边框 |

如果一份文档主要依赖右栏的那些特性,桥不会假装没事:落在共同元素集之外的内容会按设计被静默丢弃。
需要完全保真的 DOCX 往返,请直接使用
Expand Down
122 changes: 113 additions & 9 deletions src/Internal/FromPhpWordMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@
use Dskripchenko\PhpDocx\Style\Alignment;
use Dskripchenko\PhpDocx\Style\CellStyle;
use Dskripchenko\PhpDocx\Style\ParagraphStyle;
use Dskripchenko\PhpDocx\Style\HorizontalAnchor;
use Dskripchenko\PhpDocx\Style\ImagePosition;
use Dskripchenko\PhpDocx\Style\ImageWrap;
use Dskripchenko\PhpDocx\Style\Orientation;
use Dskripchenko\PhpDocx\Style\PageSetup;
use Dskripchenko\PhpDocx\Style\PaperSize;
use Dskripchenko\PhpDocx\Style\PositionAlign;
use Dskripchenko\PhpDocx\Style\RunStyle;
use Dskripchenko\PhpDocx\Style\SectionStart;
use Dskripchenko\PhpDocx\Style\VerticalAnchor;
use PhpOffice\PhpWord\Element\AbstractElement;
use PhpOffice\PhpWord\Element\Cell;
use PhpOffice\PhpWord\Element\Link;
Expand All @@ -41,7 +50,9 @@
use PhpOffice\PhpWord\SimpleType\Jc;
use PhpOffice\PhpWord\Style\Font;
use PhpOffice\PhpWord\Style\ListItem as ListItemStyle;
use PhpOffice\PhpWord\Style\Image as WordImageStyle;
use PhpOffice\PhpWord\Style\Paragraph as WordParagraphStyle;
use PhpOffice\PhpWord\Style\Section as WordSectionStyle;

/**
* PhpWord object model → php-docx AST, on the shared element set (see
Expand All @@ -52,21 +63,64 @@ final class FromPhpWordMapper
{
public function map(PhpWord $phpWord): Document
{
$body = [];
$header = [];
$footer = [];

$sections = [];
foreach ($phpWord->getSections() as $section) {
// Headers and footers by kind: default, first, even (PhpWord's names too).
$headers = ['default' => [], 'first' => [], 'even' => []];
foreach ($section->getHeaders() as $h) {
$header = array_merge($header, $this->mapElements($h->getElements()));
$type = array_key_exists($h->getType(), $headers) ? $h->getType() : 'default';
$headers[$type] = array_merge($headers[$type], $this->mapElements($h->getElements()));
}
$footers = ['default' => [], 'first' => [], 'even' => []];
foreach ($section->getFooters() as $f) {
$footer = array_merge($footer, $this->mapElements($f->getElements()));
$type = array_key_exists($f->getType(), $footers) ? $f->getType() : 'default';
$footers[$type] = array_merge($footers[$type], $this->mapElements($f->getElements()));
}
$body = array_merge($body, $this->mapElements($section->getElements()));
$style = $section->getStyle() ?? new WordSectionStyle;
$sections[] = new Section(
body: $this->mapElements($section->getElements()),
header: $headers['default'],
footer: $footers['default'],
pageSetup: $this->pageSetup($style),
firstHeader: $headers['first'],
firstFooter: $footers['first'],
evenHeader: $headers['even'],
evenFooter: $footers['even'],
start: SectionStart::tryFrom((string) $style->getBreakType()) ?? SectionStart::NextPage,
);
}
if ($sections === []) {
$sections[] = new Section(body: []);
}

return new Document(new Section(body: $body, header: $header, footer: $footer));
return new Document($sections[0], followingSections: array_slice($sections, 1));
}

/** A PhpWord section's page (twips): a preset paper size when one matches, else the size as it is. */
private function pageSetup(WordSectionStyle $style): PageSetup
{
$width = (int) round((float) $style->getPageSizeW());
$height = (int) round((float) $style->getPageSizeH());
$paper = null;
foreach (PaperSize::cases() as $size) {
if (abs($size->widthTwips() - min($width, $height)) <= 2 && abs($size->heightTwips() - max($width, $height)) <= 2) {
$paper = $size;
break;
}
}

return new PageSetup(
paperSize: $paper ?? PaperSize::A4,
orientation: $style->getOrientation() === WordSectionStyle::ORIENTATION_LANDSCAPE ? Orientation::Landscape : Orientation::Portrait,
marginTopTwips: (int) round((float) $style->getMarginTop()),
marginRightTwips: (int) round((float) $style->getMarginRight()),
marginBottomTwips: (int) round((float) $style->getMarginBottom()),
marginLeftTwips: (int) round((float) $style->getMarginLeft()),
headerOffsetTwips: (int) round((float) $style->getHeaderHeight()),
footerOffsetTwips: (int) round((float) $style->getFooterHeight()),
widthTwips: $paper === null && $width > 0 && $height > 0 ? $width : null,
heightTwips: $paper === null && $width > 0 && $height > 0 ? $height : null,
);
}

/**
Expand Down Expand Up @@ -207,7 +261,57 @@ private function mapImage(WordImage $el): ?Image
$wPt = (float) ($style?->getWidth() ?? 96.0);
$hPt = (float) ($style?->getHeight() ?? 96.0);

return new Image($binary, $format, (int) round($wPt * 12700), (int) round($hPt * 12700));
return new Image(
$binary,
$format,
(int) round($wPt * 12700),
(int) round($hPt * 12700),
position: $style === null ? null : $this->floating($style),
);
}

/**
* A floating picture's place (PhpWord writes a picture floating unless
* its wrap is inline); null for a picture in the line of text. Origins
* php-docx has no case for are read as the nearest one: the margin areas
* as the margin, the text as the paragraph.
*/
private function floating(WordImageStyle $style): ?ImagePosition
{
// PhpWord leaves these null until set, its docblocks say string.
$wrap = (string) $style->getWrappingStyle();
if ($wrap === '' || $wrap === WordImageStyle::WRAPPING_STYLE_INLINE) {
return null;
}
$horizontal = PositionAlign::tryFrom((string) $style->getPosHorizontal());
$vertical = PositionAlign::tryFrom((string) $style->getPosVertical());

return new ImagePosition(
horizontalFrom: match ((string) $style->getPosHorizontalRel()) {
WordImageStyle::POSITION_RELATIVE_TO_PAGE => HorizontalAnchor::Page,
WordImageStyle::POSITION_RELATIVE_TO_COLUMN, '' => HorizontalAnchor::Column,
WordImageStyle::POSITION_RELATIVE_TO_CHAR => HorizontalAnchor::Character,
default => HorizontalAnchor::Margin,
},
xEmu: (int) round((float) $style->getMarginLeft() * 12700),
verticalFrom: match ((string) $style->getPosVerticalRel()) {
WordImageStyle::POSITION_RELATIVE_TO_PAGE => VerticalAnchor::Page,
WordImageStyle::POSITION_RELATIVE_TO_LINE => VerticalAnchor::Line,
WordImageStyle::POSITION_RELATIVE_TO_TEXT, '' => VerticalAnchor::Paragraph,
default => VerticalAnchor::Margin,
},
yEmu: (int) round((float) $style->getMarginTop() * 12700),
// Only the alignments of their own axis: PhpWord takes left/right
// for vertical ones too, php-docx refuses them.
horizontalAlign: in_array($horizontal, [PositionAlign::Top, PositionAlign::Bottom], true) ? null : $horizontal,
verticalAlign: in_array($vertical, [PositionAlign::Left, PositionAlign::Right], true) ? null : $vertical,
behindText: $wrap === WordImageStyle::WRAPPING_STYLE_BEHIND,
wrap: match ($wrap) {
WordImageStyle::WRAPPING_STYLE_SQUARE, WordImageStyle::WRAPPING_STYLE_TIGHT, 'through' => ImageWrap::Square,
'topAndBottom' => ImageWrap::TopAndBottom,
default => ImageWrap::None,
},
);
}

private function mapTable(WordTable $el): AstTable
Expand Down
Loading
Loading