diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f7c2067 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Removed + +- The v1 compatibility shim (`dicom_tag`, `dicom_convert`, `dicom_net`, `is_dcm`, `Execute`). Deprecated in 2.0.0, it is removed here; the namespaced API is the only surface. This is a breaking change targeted for **3.0.0** and currently being soaked as a pre-release (RC). + +## [2.0.0] - 2026-06-29 + +Complete clean-room rewrite of the library for modern PHP. Introduces a namespaced, fully-typed object API with exception-based error handling, alongside a drop-in compatibility layer so existing v1 code keeps running unchanged. Relicensed to Apache-2.0. + +The library continues to drive the DCMTK command-line toolkit (a runtime dependency installed separately, not bundled). + +### Added + +- **Namespaced object API** under PSR-4 (`DICOM\`, `PACS\`, `DCMTK\`): + - `DICOM\File` -- `open()` returns a typed `Dataset`; VR-validated typed accessors (`getText`, `getDate`, `getTime`, `getDateTime`, `getPersonName`, `getUID`, `getInteger`, `getDecimal`, `getTextList`) with matching setters; static `isDICOM()` detection. + - `DICOM\Dataset` -- raw group/element access (`get`, `put`, `all`) for tags without a typed accessor. + - `DICOM\Tag` -- enum of known tags. + - `DICOM\Value\*` -- `Date`, `Time`, `DateTime`, `PersonName`, `UID` value objects with parsing, formatting, and component access. + - `DICOM\Convert` -- `toJPEG`, `toThumbnail`, `toVideo` (multiframe), `fromJpeg`, `fromPdf`. + - `DICOM\Compress` -- `compress` / `decompress` with a `Compression` factory (`losslessSV1`, `lossless`, `baseline`, `extended`). + - `PACS\EchoSCU` (C-ECHO), `PACS\SCU` (C-STORE send: `send`, `sendDirectory`), `PACS\SCP` (C-STORE receive server), with `PACS\Peer`, `PACS\Association`, and `PACS\TransferSyntaxProposal` for connection, AE, and transfer-syntax negotiation. + - `DCMTK\Toolkit` -- locates the DCMTK binaries (on `PATH` or an explicit directory). +- **Exception-based error model** -- `DICOM\Exception\IOException`, `PACS\Exception\NetworkException`, and `InvalidArgumentException` replace v1's sentinel return values; failures surface at the call site. +- **Backward-compatibility shim** -- the v1 global surface (`dicom_tag`, `dicom_convert`, `dicom_net`, `is_dcm`, `Execute`) reimplemented on top of v2, so v1.1.0 code runs unchanged while emitting deprecation notices that point at the v2 equivalents. +- **Independent integration test suite** -- conversions, tag operations, compression, and network calls are cross-validated with pydicom and pynetdicom, verifying that produced files are correct when read by a separate DICOM implementation rather than round-tripped through the tools that wrote them. CI runs the suite on PHP 8.5 + DCMTK. +- **Documentation** -- `README.md` rewritten v2-first with examples verified against live PHP and a full API reference; `docs/migration-v1-to-v2.md` with a per-element v1-to-v2 mapping; before/after migration recipes in `examples/`. + +### Changed + +- **License: relicensed from MIT to Apache-2.0.** The prior MIT declaration asserted terms the project could not grant (the 1.x line was a fork of an originally unlicensed library). The clean-room rewrite is sole-authored, written without reference to the legacy source, and released under Apache-2.0, with provenance recorded in `NOTICE`. +- **Minimum PHP raised to 8.5** (the 1.x line required 8.0+). +- Tag access is typed and VR-validated by default; raw `'gggg,eeee'`-style addressing remains available through `DICOM\Dataset` for tags without a typed accessor. +- Error handling moved from sentinel/return-code style to exceptions throughout. + +### Fixed + +- **JPEG-to-DICOM works on current DCMTK.** `Convert::fromJpeg()` generates the required UIDs directly and supplies tags via typed setters, replacing the v1 `dcm2xml` template path whose warning handling prevented pixel-data embedding. This resolves the `jpg_to_dcm()` issue tracked as a known limitation in 1.1.0. + +### Deprecated + +- The entire v1 global surface (`dicom_tag`, `dicom_convert`, `dicom_net`, `is_dcm`, `Execute`). It remains fully functional via the compatibility shim throughout the 2.x line but emits deprecation notices, and is scheduled for removal in 3.0.0. + +### Notes + +- No code is shared with the original `class_dicom.php` by Dean Vaughan, which is acknowledged as a conceptual predecessor only. See `NOTICE`. + +## [1.1.0] - 2026-05-27 + +PHP 8.x compatibility, bug fixes, an integration test suite, and documentation, on the original procedural library. No breaking API changes from 1.0.0. + +### Added + +- Integration test suite (`tests/`) using pydicom and pynetdicom for independent cross-validation, so every conversion, tag operation, and network call is verified by a separate DICOM implementation rather than read back by the same tools that wrote it. +- Full `README.md` with usage examples verified against live PHP, an API reference, and testing instructions. +- `ROADMAP.md` outlining the planned v2.0.0 modern-PHP refactor and feature expansion. + +### Changed + +- Now requires PHP 8.0+ (declared in `composer.json`); tested through PHP 8.4. + +### Fixed + +- `compress()` was a silent no-op: an undefined variable meant the method did nothing on every call. +- `dcmcjpeg` and `dcmdjpeg` binary paths were swapped, so compress invoked the decompressor and vice versa. Corrected in both the Windows and Linux path blocks. +- PHP 8.2 dynamic property deprecations: added the missing `$template` and `$temp_dir` property declarations on `dicom_convert`. + +### Known issues + +- `jpg_to_dcm()` did not work on current DCMTK versions due to warning-handling logic that prevented pixel-data embedding. (Resolved in 2.0.0.) + +## [1.0.0] - 2025-09-02 + +- Initial Packagist release of the procedural `class_dicom.php` library (a fork of the original by Dean Vaughan): DICOM tag read/write, JPEG conversion, compression, and DICOM send/receive, wrapping the DCMTK command-line tools. + +[Unreleased]: https://github.com/rbraunm/class_dicom.php/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/rbraunm/class_dicom.php/compare/v1.1.0...v2.0.0 +[1.1.0]: https://github.com/rbraunm/class_dicom.php/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/rbraunm/class_dicom.php/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 393bc1d..2527810 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,19 +4,11 @@ Guidance for AI agents working in this repository. **Read `CONTRIBUTING.md` firs it is the canonical guide for conventions, the dev/test environment, and CI. The points below are the ones that must not be missed. -## Critical: clean-room rule - -This is a clean-room, Apache-2.0 v2 rewrite. Never open, read, or reference the -legacy `class_dicom.php` source. Implement only from the DICOM standard (NEMA PS3), -the DCMTK documentation, and the published v1 surface (README, `examples/`, -`docs/v1-surface.json`). Reflection and black-box behavioral observation are -allowed; reading the legacy source is not. - ## Where things live - Conventions, testing standards, dev/test environment, CI: `CONTRIBUTING.md`. - Tooling reference (provision, `ct_exec`, research harness): `tools/README.md`. -- Plan and phase status: `docs/v2-rewrite-plan.md`, `ROADMAP.md`. +- Roadmap: `ROADMAP.md`. ## Working norms @@ -36,7 +28,7 @@ the human-developer paths in `CONTRIBUTING.md` (the CI image and the Proxmox LXC an agent has no container runtime, so it installs the toolchain directly. Egress hosts to whitelist are in `CONTRIBUTING.md` (Agent sandbox). -The target is PHP 8.5 (the v2 baseline; see `docs/v2-rewrite-plan.md`) and the +The target is PHP 8.5 and the stable DCMTK the distribution ships -- in this sandbox that is Ubuntu's `dcmtk`, which can differ from the pinned CI image. CI stays the authority for a green suite; the sandbox is for development iteration. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60a138c..8e64d86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,19 +1,8 @@ # Contributing -This is a clean-room, Apache-2.0 v2 rewrite of a PHP DICOM library that wraps the -DCMTK command-line tools. This document is the working guide for everyone (human -or agent) touching the code: how to source an implementation, the conventions, how -to run the suite, and how CI gates changes. - -## Clean-room rule (v2) - -The legacy `class_dicom.php` is not opened, read, or referenced while writing v2 -code. Implement only from the DICOM standard (NEMA PS3), the DCMTK documentation, -and the published v1 surface (README and `examples/`). The standard is the source -of truth. Interface facts (names, signatures, observable behavior) are reusable; -expression (bodies, structure, comments) is not. Reflection and black-box -behavioral observation are permitted; reading the legacy source is not. See -`docs/v2-rewrite-plan.md` and the frozen `docs/v1-surface.json`. +This is an Apache-2.0 PHP DICOM library that wraps the DCMTK command-line tools. +This document is the working guide for everyone (human or agent) touching the +code: the conventions, how to run the suite, and how CI gates changes. ## Source file headers diff --git a/README.md b/README.md index aac9dfa..ada244c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ A PHP library for working with DICOM medical images: tag reading and writing, JPEG conversion, compression, multiframe-to-video, and DICOM networking (C-ECHO, C-STORE send and receive). It drives the [DCMTK](https://dicom.offis.de/dcmtk.php.en) command-line toolkit under a typed, namespaced PHP API. -Version 2 is a clean-room rewrite on PHP 8.5 with a first-class object API (`DICOM\*`, `PACS\*`) and value objects for dates, names, and UIDs. The original procedural surface (`dicom_tag`, `dicom_convert`, `dicom_net`, and the global helpers) is preserved as a compatibility shim so v1 code keeps running unchanged -- it now emits deprecation notices pointing at the v2 equivalents. See [Migrating from v1](#migrating-from-v1). +A modern PHP 8.5 library for reading and writing DICOM files, converting and compressing images, and DICOM networking (C-ECHO, C-STORE), wrapping the DCMTK toolkit. It exposes a typed object API (`DICOM\*`, `PACS\*`) with value objects for dates, names, and UIDs. -Originally created by Dean Vaughan ([deanvaughan.org](http://www.deanvaughan.org/projects/class_dicom_php/)). +Acknowledges the original `class_dicom.php` by Dean Vaughan ([deanvaughan.org](http://www.deanvaughan.org/projects/class_dicom_php/)) as a conceptual predecessor; this library shares no code with it (see [NOTICE](NOTICE)). ## Requirements @@ -155,24 +155,6 @@ while ($process->isRunning()) { } ``` -## Migrating from v1 - -Existing v1 code runs unchanged against the compatibility shim, which emits deprecation notices: - -```php -$d = new dicom_tag('/path/to/image.dcm'); // deprecated; use DICOM\File -$d->load_tags(); -$name = $d->get_tag('0010', '0010'); -``` - -The `examples/` directory contains a worked migration for every operation: each script shows the v1 form as a "Before" block and the runnable v2-native "After" that bypasses the shim. A full element-by-element mapping lives in [`docs/migration-v1-to-v2.md`](docs/migration-v1-to-v2.md). - -A few migration notes: - -- v1's raw `'gggg,eeee'` addresses were only necessary because v1 had no typed access. Prefer the typed accessors; the raw `Dataset` get/put remains for tags without one. -- v1's `jpg_to_dcm()` XML template is gone -- `Convert::fromJpeg()` generates the UIDs and typed setters supply the tags. -- `dicom_net::$transfer_syntax` was inert in v1 (it set nothing); the shim keeps it inert and warns. Use `PACS\TransferSyntaxProposal` with `PACS\SCU` for real negotiation. - ## Testing The suite runs under PHPUnit, with independent oracles (pydicom and pynetdicom) validating that files the library produces are correct when read by a separate implementation -- not just round-tripped through the same tools that wrote them. @@ -190,7 +172,7 @@ composer test ## API reference -### v2 API +### API | Namespace / class | Purpose | |---|---| @@ -206,19 +188,9 @@ composer test | `PACS\Peer` / `PACS\Association` / `PACS\TransferSyntaxProposal` | Connection, AE, and negotiation settings | | `DCMTK\Toolkit` | Locates the DCMTK binaries (PATH or an explicit directory) | -### Compatibility shim (deprecated) - -| Class / function | v2 replacement | -|---|---| -| `dicom_tag` | `DICOM\File` / `DICOM\Dataset` | -| `dicom_convert` | `DICOM\Convert` / `DICOM\Compress` | -| `dicom_net` | `PACS\EchoSCU` / `PACS\SCU` / `PACS\SCP` | -| `is_dcm($file)` | `DICOM\File::isDICOM($path)` | -| `Execute($command)` | `DCMTK\Tool` (internal) | - ## Examples -Each script in `examples/` is a v1-to-v2 migration recipe. +Each script in `examples/` demonstrates one operation against the bundled fixture. | File | Operation | |---|---| diff --git a/ROADMAP.md b/ROADMAP.md index 70aae14..ad56701 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,47 +1,41 @@ # Roadmap -## v2.0.0 (priority #1) -- Clean-room rewrite & Apache-2.0 relicense - -This package is a fork of Dean Vaughan's originally-unlicensed `class_dicom.php`, so the current `MIT` declaration asserts terms we can't grant, and any refactor of the existing file inherits the same defect. The fix is a **from-scratch, clean-room reimplementation** -- sole-authored, written without reference to the legacy source -- released under **Apache-2.0**. v2 faithfully replaces v1's public surface as a clean, typed PHP wrapper over DCMTK, and ships a backward-compatibility shim so existing Packagist consumers move to `^2` without code changes. - -The full plan -- clean-room discipline, licensing and provenance artifacts, architecture, capability scope, research methodology, phased delivery, testing, and done criteria -- lives in **[docs/v2-rewrite-plan.md](docs/v2-rewrite-plan.md)** and is maintained there only. This roadmap intentionally does not restate it. - -**Status (current):** the clean-room wrapper is landed through the `DICOM\` and `PACS\` layers -- detection, tags, conversion, compression, and DICOM networking (C-ECHO, C-STORE SCU and SCP) are complete and CI-green. The backward-compatibility shim and the docs/release step remain; phase-level detail is in the plan. - -## v1.1.0 (current) - -PHP 8.x compatibility fixes and an integration test suite. - -- Fixed swapped `dcmcjpeg`/`dcmdjpeg` binary definitions (compress and decompress were calling each other's binaries) -- Fixed undefined variable in `compress()` that caused the method to silently do nothing -- Added missing property declarations on `dicom_convert` (`$template`, `$temp_dir`) to eliminate PHP 8.2 dynamic property deprecations -- Added `php >= 8.0` requirement to `composer.json` -- Added integration test suite with pydicom/pynetdicom cross-validation covering tags, conversions, compression, and DICOM networking - -### Known issues - -- `jpg_to_dcm()` returns early when `xml2dcm` produces any output, including non-fatal warnings. The bundled XML template triggers a SOPInstanceUID mismatch warning on current DCMTK versions, preventing the `img2dcm` step from embedding pixel data. The output file contains only a DICOM header with no image. - -## v3 -- expansion beyond v1 - -v2 deliberately stops at v1's surface (see the plan). v3 is the post-v1 line: it drops the deprecated compatibility shim and grows the wrapper to cover more of the DCMTK toolset than v1 ever did. The goal is not full pydicom/pynetdicom parity but the operations that matter in a PHP web application receiving, routing, and serving DICOM images. Each item is another DCMTK tool wrapped under the same discipline as v2. - -### DICOM networking - -- **C-FIND (Query).** Query a remote PACS for studies, series, or instances by patient name, date range, modality, accession number, or study UID (`findscu`). The single most-requested DICOM network feature for web applications. -- **C-MOVE / C-GET (Retrieve).** Trigger a PACS to send images to a specified AE title, or pull them directly (`movescu` / `getscu`). -- **Association negotiation control.** Expose transfer syntax and abstract syntax negotiation so callers can control what gets proposed and accepted, rather than the fixed set v1's `send_dcm` hard-codes. -- **TLS support.** DICOM TLS for C-STORE and C-FIND, since many hospital networks now require encrypted DICOM traffic. - -### Image handling - -- **Pixel data access.** Decode pixel data into a PHP array or GD/Imagick resource for server-side processing without converting to JPEG first, including windowing and level adjustment. -- **JPEG 2000 support.** Beyond v1's JPEG baseline/lossless: JPEG 2000 lossless and lossy (transfer syntaxes 1.2.840.10008.1.2.4.90 and .91), where the DCMTK build provides it. -- **Multi-frame handling.** Extract individual frames as images without converting the whole stack to video. Frame-level access is essential for ultrasound and fluoroscopy workflows. - -### Metadata and conformance - -- **DICOMDIR support.** Read and write DICOMDIR files for media interchange (CD/DVD, portable media). -- **Structured report reading.** Parse SR documents (radiologist reports, CAD results) into a traversable PHP structure. -- **UID generation.** Generate conformant DICOM UIDs with a registered root, so files the library creates are traceable and don't collide. -- **Conformance statement.** Document which SOP classes, transfer syntaxes, and DIMSE services the library supports, in the format PACS administrators expect. +The line beyond the current release grows the wrapper to cover more of the DCMTK +toolset, focused on the operations that matter in a PHP web application receiving, +routing, and serving DICOM images. The goal is not full pydicom/pynetdicom parity. +Each item is another DCMTK tool wrapped under the same discipline as the current +release. + +## DICOM networking + +- **C-FIND (query).** Query a remote PACS for studies, series, or instances by + patient name, date range, modality, accession number, or study UID (`findscu`). + The single most-requested DICOM network feature for web applications. +- **C-MOVE / C-GET (retrieve).** Trigger a PACS to send images to a specified AE + title, or pull them directly (`movescu` / `getscu`). +- **Association negotiation control.** Expose transfer-syntax and abstract-syntax + negotiation so callers can control what gets proposed and accepted. +- **TLS support.** DICOM TLS for C-STORE and C-FIND, since many hospital networks + now require encrypted DICOM traffic. + +## Image handling + +- **Pixel-data access.** Decode pixel data into a PHP array or GD/Imagick resource + for server-side processing without converting to JPEG first, including windowing + and level adjustment. +- **JPEG 2000 support.** JPEG 2000 lossless and lossy (transfer syntaxes + 1.2.840.10008.1.2.4.90 and .91), where the DCMTK build provides it. +- **Multi-frame handling.** Extract individual frames as images without converting + the whole stack to video. Frame-level access is essential for ultrasound and + fluoroscopy workflows. + +## Metadata and conformance + +- **DICOMDIR support.** Read and write DICOMDIR files for media interchange + (CD/DVD, portable media). +- **Structured report reading.** Parse SR documents (radiologist reports, CAD + results) into a traversable PHP structure. +- **UID generation.** Generate conformant DICOM UIDs with a registered root, so + files the library creates are traceable and do not collide. +- **Conformance statement.** Document which SOP classes, transfer syntaxes, and + DIMSE services the library supports, in the format PACS administrators expect. diff --git a/compat/dicom_convert.php b/compat/dicom_convert.php deleted file mode 100644 index 7d5e5d4..0000000 --- a/compat/dicom_convert.php +++ /dev/null @@ -1,290 +0,0 @@ -file = $file; - } - - /** - * Render the image to "{file}.jpg" at $jpg_quality, set $jpg_file to that path, - * and return it. Tries VOI window 1, falling back to min-max windowing. - */ - public function dcm_to_jpg() - { - $out = (string) $this->file . '.jpg'; - $this->jpg_file = $out; - $quality = (int) ($this->jpg_quality ?: 100); - - ShimContract::runWithVoiFallback( - 'dcm_to_jpg() is deprecated; use DICOM\\Convert::toJPEG() in new code.', - 'dcm_to_jpg(): the image has no stored VOI window, so it fell back to ' - . 'min-max windowing. This silent fallback will be removed in v3 -- pass ' - . 'an image with a VOI window, or migrate to DICOM\\Convert with an ' - . 'explicit Windowing.', - fn (): mixed => (new Convert(File::open((string) $this->file))) - ->toJPEG($out, Windowing::useWindow(1), $quality), - fn (): mixed => (new Convert(File::open((string) $this->file))) - ->toJPEG($out, Windowing::minMax(), $quality), - ); - - return $out; - } - - /** - * Render a thumbnail to "{file}_tn.jpg" -- fixed quality 75, scaled to $tn_size - * wide -- set $tn_file to that path, and return it. Same VOI-window fallback. - */ - public function dcm_to_tn() - { - $out = (string) $this->file . '_tn.jpg'; - $this->tn_file = $out; - $size = (int) ($this->tn_size ?: 125); - - ShimContract::runWithVoiFallback( - 'dcm_to_tn() is deprecated; use DICOM\\Convert::toThumbnail() in new code.', - 'dcm_to_tn(): the image has no stored VOI window, so it fell back to ' - . 'min-max windowing. This silent fallback will be removed in v3 -- pass ' - . 'an image with a VOI window, or migrate to DICOM\\Convert with an ' - . 'explicit Windowing.', - fn (): mixed => (new Convert(File::open((string) $this->file))) - ->toThumbnail($out, $size, 75, Windowing::useWindow(1)), - fn (): mixed => (new Convert(File::open((string) $this->file))) - ->toThumbnail($out, $size, 75, Windowing::minMax()), - ); - - return $out; - } - - /** - * Compress the image to JPEG lossless SV1 (v1's default), writing to $new_file - * or overwriting the input when $new_file is empty. Returns the output path. - * - * @return string - */ - public function compress($new_file = '') - { - $out = $new_file !== '' ? (string) $new_file : (string) $this->file; - - return ShimContract::run( - 'compress() is deprecated; use DICOM\\Compress::compress() in new code.', - function () use ($out): string { - (new Compress(File::open((string) $this->file)))->compress($out, Compression::losslessSV1()); - - return $out; - }, - $out, - ); - } - - /** - * Decompress the image, writing to $new_file or overwriting the input when - * $new_file is empty. Returns the output path. - * - * @return string - */ - public function uncompress($new_file = '') - { - $out = $new_file !== '' ? (string) $new_file : (string) $this->file; - - return ShimContract::run( - 'uncompress() is deprecated; use DICOM\\Compress::decompress() in new code.', - function () use ($out): string { - (new Compress(File::open((string) $this->file)))->decompress($out); - - return $out; - }, - $out, - ); - } - - /** - * Create a DICOM from the JPEG at $jpg_file, apply the "gggg,eeee" => value tags - * in $arr_info, and return the output path "{jpg_file}.dcm". v2 builds the DICOM - * via img2dcm, which mints its own type-1 UIDs, so v1's xml2dcm SOPInstanceUID - * bug cannot occur. This is improved v2 behavior, not a reproduction of v1's - * template-driven conversion -- a set $template is ignored (with a deprecation). - * - * @param array $arr_info - * @return string - */ - public function jpg_to_dcm($arr_info) - { - if ((string) $this->template !== '') { - ShimContract::deprecate( - 'jpg_to_dcm(): $template is ignored. v2 builds the DICOM via img2dcm, ' - . 'which mints its own type-1 UIDs; move any template tags into the ' - . '$arr_info map. This compatibility behavior may change in v3.', - ); - } - $out = (string) $this->jpg_file . '.dcm'; - - return ShimContract::run( - 'jpg_to_dcm() is deprecated; use DICOM\\Convert::fromJpeg() in new code.', - function () use ($out, $arr_info): string { - Convert::fromJpeg([(string) $this->jpg_file], $out); - ShimContract::applyTags($out, (array) $arr_info); - - return $out; - }, - $out, - ); - } - - /** - * Create an Encapsulated PDF DICOM from the PDF at $file, apply the - * "gggg,eeee" => value tags in $arr_info, and return "{file}.dcm". This is - * working v2 functionality (v1's PDF conversion was non-functional), documented - * as improved behavior rather than a validated v1 reproduction. - * - * @param array $arr_info - * @return string - */ - public function pdf_to_dcm($arr_info) - { - $out = (string) $this->file . '.dcm'; - - return ShimContract::run( - 'pdf_to_dcm() is deprecated; use DICOM\\Convert::fromPdf() in new code. ' - . 'This is improved v2 Encapsulated PDF conversion, not a reproduction of ' - . 'v1 (whose PDF conversion was non-functional).', - function () use ($out, $arr_info): string { - Convert::fromPdf((string) $this->file, $out); - ShimContract::applyTags($out, (array) $arr_info); - - return $out; - }, - $out, - ); - } - - /** - * Deprecated alias of pdf_to_dcm(), preserved from v1's surface. - * - * @param array $arr_info - * @return string - */ - public function pdf_to_dcmcr($arr_info) - { - ShimContract::deprecate( - 'pdf_to_dcmcr() is a deprecated alias of pdf_to_dcm(); use ' - . 'DICOM\\Convert::fromPdf() in new code.', - ); - - return $this->pdf_to_dcm($arr_info); - } - - /** - * Assemble the multiframe image into a video at - * "{temp_dir}/{basename(file)}.{format}" and return that path. Only the mp4 - * format is supported. The temp_dir is created if missing. - * - * One deliberate, safer-than-v1 change: $framerate is honored as a - * frames-per-second cine rate. v1 ignored its $framerate argument and always - * fed ffmpeg a fixed 10 fps input rate, so callers who passed a value got no - * effect; here the value takes effect. - * - * This is the narrow v1 surface -- format, framerate, and output directory - * only. DICOM\\Convert::toVideo() exposes the richer timing (seconds-per-frame, - * frame multiplier), scaling, quality, and windowing controls. - * - * @param string $format output container; only 'mp4' is supported - * @param int $framerate frames per second (v1 default 24) - * @param string $temp_dir output directory (v1 default './video_temp') - * @return string the output path - */ - public function multiframe_to_video($format = 'mp4', $framerate = 24, $temp_dir = './video_temp') - { - $format = (string) $format; - if (strtolower($format) !== 'mp4') { - throw new \InvalidArgumentException( - "multiframe_to_video(): only the 'mp4' format is supported, got '{$format}'." - ); - } - $directory = (string) $temp_dir; - if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { - throw new \RuntimeException( - "multiframe_to_video(): could not create output directory '{$directory}'." - ); - } - $out = rtrim($directory, '/') . '/' . basename((string) $this->file) . '.' . $format; - - return ShimContract::run( - 'multiframe_to_video() is deprecated; use DICOM\\Convert::toVideo() in new ' - . 'code. Unlike v1, this honors $framerate (v1 ignored it and always used ' - . '10 fps).', - function () use ($out, $framerate): string { - (new Convert(File::open((string) $this->file))) - ->toVideo($out, FrameTiming::framesPerSecond((float) $framerate), VideoFormat::mp4()); - - return $out; - }, - $out, - ); - } -} diff --git a/compat/dicom_net.php b/compat/dicom_net.php deleted file mode 100644 index 37ed897..0000000 --- a/compat/dicom_net.php +++ /dev/null @@ -1,196 +0,0 @@ -echo_acse_timeout; - $dimse = $this->echo_dimse_timeout; - $connection = $this->echo_connection_timeout; - - return ShimContract::run( - 'echoscu() is deprecated; use PACS\\EchoSCU in new code.', - function () use ($host, $port, $my_ae, $remote_ae, $acse, $dimse, $connection): int { - $peer = new Peer((string) $host, (int) $port, (string) $remote_ae); - $association = new Association((string) $my_ae, $acse, $dimse, $connection); - (new EchoSCU($peer, $association))->verify(); - - return 0; - }, - static fn (\Throwable $exception): string => $exception->getMessage(), - ); - } - - /** - * C-STORE send of $this->file to $host:$port. With a falsy $batch it sends the - * single file; with a truthy $batch it sends every file in that file's directory - * (non-recursive), v1's batch mode. Returns 0 on success or the error output - * string on failure. $my_ae is the calling AE, $target_ae the called. - * - * @return int|string 0 on success, error string on failure - */ - public function send_dcm($host, $port, $my_ae = 'DEANO', $remote_ae = 'DEANO', $send_batch = 0) - { - $file = (string) $this->file; - $acse = $this->send_acse_timeout; - $dimse = $this->send_dimse_timeout; - $connection = $this->send_connection_timeout; - - if ((string) $this->transfer_syntax !== '') { - ShimContract::deprecate( - 'dicom_net::$transfer_syntax is ignored (as in v1); use ' - . 'PACS\TransferSyntaxProposal with PACS\SCU for transfer-syntax control.', - ); - } - - return ShimContract::run( - 'send_dcm() is deprecated; use PACS\\SCU::send()/sendDirectory() in new code.', - function () use ($host, $port, $my_ae, $remote_ae, $send_batch, $file, $acse, $dimse, $connection): int { - $scu = new SCU( - new Peer((string) $host, (int) $port, (string) $remote_ae), - new Association((string) $my_ae, $acse, $dimse, $connection), - ); - if ($send_batch) { - $scu->sendDirectory(dirname($file)); - } else { - $scu->send(File::open($file)); - } - - return 0; - }, - static fn (\Throwable $exception): string => $exception->getMessage(), - ); - } - - /** - * Run a blocking C-STORE SCP (storescp) that receives objects into $storage_dir. - * $handler_script, if given, is run after each received object as a bare command - * with v1's placeholders appended -- " #p #f #c #a" (storage dir, file, - * called AE, calling AE) -- so it must be executable with a shebang. $config_file, - * if given, selects accepted presentation contexts (storescp -xf Default); - * otherwise all transfer syntaxes are accepted. A truthy $debug adds -v -d. - * - * Server timeouts and the fork/host-lookup/blocking behavior come from the - * $server_*_timeout, $fork, $disable_host_lookup, and $blocking properties. - * Blocking (the default, matching v1) runs in the foreground until the process - * exits and returns null; non-blocking returns the SCPProcess handle. - * - * @return \PACS\SCPProcess|null the handle when non-blocking, otherwise null - */ - public function store_server($port, $dcm_dir, $handler_script, $config_file = '', $debug = 0) - { - $port = (int) $port; - $storage = (string) $dcm_dir; - $handler = (string) $handler_script; - $config = (string) $config_file; - $acse = $this->server_acse_timeout; - $dimse = $this->server_dimse_timeout; - $fork = $this->fork; - $disableHostLookup = $this->disable_host_lookup; - $blocking = $this->blocking; - - return ShimContract::run( - 'store_server() is deprecated; use PACS\\SCP in new code.', - function () use ($port, $storage, $handler, $config, $debug, $acse, $dimse, $fork, $disableHostLookup, $blocking) { - if (!is_dir($storage) && !mkdir($storage, 0775, true) && !is_dir($storage)) { - throw new \RuntimeException("store_server(): could not create storage directory '{$storage}'."); - } - $scp = new SCP( - port: $port, - outputDirectory: $storage, - postReceiveCommand: $handler !== '' ? $handler . ' #p #f #c #a' : null, - forkPerAssociation: $fork, - presentationConfigFile: $config !== '' ? $config : null, - debug: (bool) $debug, - disableHostLookup: $disableHostLookup, - acseTimeoutSeconds: $acse, - dimseTimeoutSeconds: $dimse, - ); - $process = $scp->start(); - if (!$blocking) { - return $process; - } - while ($process->isRunning()) { - usleep(200000); - } - - return null; - }, - null, - ); - } -} diff --git a/compat/dicom_tag.php b/compat/dicom_tag.php deleted file mode 100644 index 424042d..0000000 --- a/compat/dicom_tag.php +++ /dev/null @@ -1,109 +0,0 @@ - "gggg,eeee" => value, populated by load_tags(). */ - public $tags = []; - - public function __construct($file = '') - { - $this->file = $file; - } - - /** - * Look up a single tag from the loaded $tags map. Returns '' when the tag is - * absent (including before load_tags() has been called), never throwing -- v1's - * loose behavior. - */ - public function get_tag($group, $element) - { - ShimContract::deprecate('get_tag() is deprecated; use DICOM\\Dataset::get() in new code.'); - - return $this->tags[strtolower($group . ',' . $element)] ?? ''; - } - - /** - * Read every top-level tag into $tags, with well-known UIDs rendered as - * dictionary names. Returns null. On a read failure $tags is left empty. - */ - public function load_tags() - { - $this->tags = ShimContract::run( - 'load_tags() is deprecated; use DICOM\\Dataset::all() in new code.', - fn (): array => ShimContract::readNameRenderedTags(new Toolkit(), (string) $this->file), - [], - ); - - return null; - } - - /** - * Write each "gggg,eeee" => value entry to the file via the v2 write path. - * Returns int 0 on success, or an error string on failure (a malformed key, or - * a write the toolkit rejects) -- v1's falsy-on-success contract. Does not - * refresh $tags; call load_tags() again to observe the written values. - * - * @param array $tag_arr - * @return int|string - */ - public function write_tags($tag_arr) - { - ShimContract::deprecate('write_tags() is deprecated; use DICOM\\Dataset::put() in new code.'); - - try { - $dataset = new Dataset((string) $this->file); - foreach ($tag_arr as $key => $value) { - $parts = explode(',', (string) $key); - if (count($parts) !== 2 || !ctype_xdigit($parts[0]) || !ctype_xdigit($parts[1])) { - $message = "write_tags(): malformed tag key '{$key}' (expected \"GGGG,EEEE\")."; - @trigger_error($message, E_USER_WARNING); - - return $message; - } - $dataset->put(hexdec($parts[0]), hexdec($parts[1]), (string) $value); - } - - return 0; - } catch (DICOMException | PACSException | ToolkitException $exception) { - @trigger_error($exception->getMessage(), E_USER_WARNING); - - return $exception->getMessage(); - } - } -} diff --git a/compat/functions.php b/compat/functions.php deleted file mode 100644 index bccccdd..0000000 --- a/compat/functions.php +++ /dev/null @@ -1,59 +0,0 @@ -locate('dcmdump'); - - return ShimContract::dcmdumpDetect($binary, (string) $file); - }, - 0, - ); - } -} - -if (!function_exists('Execute')) { - /** - * v1 compatibility: run $command through a shell and return its captured stdout - * (with v1's `2>&1` appended). stderr is not captured -- it inherits the parent - * process -- reproducing v1's behavior including the compound-command redirect - * quirk. The returned string is verbatim. - * - * @deprecated Shell execution is intentionally outside the v2 substrate; there - * is no v2 replacement. - */ - function Execute($command): string - { - ShimContract::deprecate( - 'Execute() is deprecated; shell execution is intentionally outside the ' - . 'v2 substrate and has no replacement.', - ); - - return ShimContract::shellCapture((string) $command); - } -} diff --git a/composer.json b/composer.json index d7c0786..7cdbf21 100644 --- a/composer.json +++ b/composer.json @@ -15,17 +15,8 @@ "psr-4": { "DICOM\\": "src/DICOM/", "PACS\\": "src/PACS/", - "DCMTK\\": "src/DCMTK/", - "Compat\\": "src/Compat/" - }, - "files": [ - "compat/functions.php" - ], - "classmap": [ - "compat/dicom_tag.php", - "compat/dicom_convert.php", - "compat/dicom_net.php" - ] + "DCMTK\\": "src/DCMTK/" + } }, "autoload-dev": { "psr-4": { diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md deleted file mode 100644 index 373cad7..0000000 --- a/docs/migration-v1-to-v2.md +++ /dev/null @@ -1,86 +0,0 @@ -# Migrating from class_dicom.php v1 to v2 - -Version 2 keeps the entire v1 surface working through a compatibility shim, so existing code runs unchanged. Each shim call emits an `E_USER_DEPRECATED` notice naming its v2 replacement. This document maps every v1 element to its v2 home; the [`examples/`](../examples) directory has a runnable migration for each operation. - -## How the shim behaves - -- Every v1 class (`dicom_tag`, `dicom_convert`, `dicom_net`) and global function (`is_dcm`, `Execute`) is still callable and still honors its v1 return contract. -- Each call triggers a deprecation notice. Run with `E_DEPRECATED | E_USER_DEPRECATED` visible to find remaining v1 usage; when none fire, the shim can be removed. -- **The error model changes when you go native.** v1 returned sentinel values (e.g. `echoscu`/`send_dcm` return `0` on success or an error string); the v2 classes throw typed exceptions instead. Migrating a call means adopting its v2 error handling. - -## Global functions - -| v1 | v2 | Notes | -|---|---|---| -| `is_dcm($file)` | `DICOM\File::isDICOM($path): bool` | v1 returned `1`/`0`; v2 returns `bool`. | -| `Execute($command)` | *(no equivalent)* | Shell execution is intentionally outside the v2 substrate. Use PHP's own process facilities if you need it. | - -## dicom_tag -> DICOM\File / DICOM\Dataset - -Read with typed accessors keyed by the `DICOM\Tag` enum -- v1's raw `'gggg,eeee'` addresses existed only because typed access did not. Fall back to the raw `Dataset` only for tags without a typed accessor. - -| v1 | v2 | Example | -|---|---|---| -| `new dicom_tag($file)` / `load_tags()` | `DICOM\File::open($path)` | [get_tags.php](../examples/get_tags.php) | -| `$d->tags` (full map) | `$file->dataset()->all()` | [get_tags.php](../examples/get_tags.php) | -| `$d->get_tag('0010', '0010')` | `$file->getPersonName(Tag::PatientName)` (typed) or `$file->dataset()->get(0x0010, 0x0010)` (raw) | [get_tags.php](../examples/get_tags.php) | -| `$d->write_tags(['0010,0010' => $v])` | `$file->setPersonName(Tag::PatientName, PersonName::fromDICOM($v))` / `setText` / ... (typed) or `(new Dataset($path))->put(0x0010, 0x0010, $v)` (raw) | [write_tags.php](../examples/write_tags.php) | -| `$d->file` | the path passed to `File::open()` / `new Dataset()` | | - -Typed accessors: `getText`/`setText`, `getInteger`/`setInteger`, `getDecimal`/`setDecimal`, `getDate`/`setDate`, `getTime`/`setTime`, `getDateTime`/`setDateTime`, `getPersonName`/`setPersonName`, `getUID`/`setUID`, `getTextList`/`setTextList`. The structured ones return/accept value objects in `DICOM\Value\*` (`Date`, `Time`, `DateTime`, `PersonName`, `UID`). - -## dicom_convert -> DICOM\Convert / DICOM\Compress - -| v1 | v2 | Example | -|---|---|---| -| `dcm_to_jpg()` | `(new Convert(File::open($f)))->toJPEG($out, quality: $q)` | [dcm_to_jpg.php](../examples/dcm_to_jpg.php) | -| `dcm_to_tn()` | `(new Convert(File::open($f)))->toThumbnail($out, widthPixels: $w)` | [dcm_to_jpg.php](../examples/dcm_to_jpg.php) | -| `jpg_to_dcm($tags)` (+ `template`, `temp_dir`) | `Convert::fromJpeg([$jpg], $out)` then typed setters | [jpg_to_dcm.php](../examples/jpg_to_dcm.php) | -| `pdf_to_dcm($info)` | `Convert::fromPdf($pdf, $out)` | | -| `pdf_to_dcmcr($info)` | `Convert::fromPdf($pdf, $out)` | | -| `compress($out)` | `(new Compress(File::open($f)))->compress($out, Compression::losslessSV1())` | [compress.php](../examples/compress.php) | -| `uncompress($out)` | `(new Compress(File::open($f)))->decompress($out)` | [uncompress.php](../examples/uncompress.php) | -| `multiframe_to_video($fmt, $fps, $tmp)` | `(new Convert(File::open($f)))->toVideo($out, FrameTiming::framesPerSecond($fps), VideoFormat::mp4())` | | - -Property and behavior changes: - -- `jpg_quality` -> the `quality:` argument to `toJPEG()`; `tn_size` -> `widthPixels:` on `toThumbnail()`. -- `template` is **obsolete**. `Convert::fromJpeg()` builds the Secondary Capture object and generates the study/series/SOP UIDs; there is no XML template. The old `jpg_to_dcm.xml` is no longer used. -- `temp_dir` is managed internally by `Convert`/`toVideo()`; you no longer supply one. -- `multiframe_to_video()` in v1 accepted a format string; v2 supports MP4 (`VideoFormat::mp4()`). `toVideo()` also exposes richer timing via `FrameTiming` (frames-per-second, seconds-per-frame, repeat-each-frame). -- `compress()` defaults to lossless SV1 (`...1.2.4.70`), matching v1; other modes are `Compression::lossless()`, `::baseline($q)`, `::extended($q)`. - -## dicom_net -> PACS\EchoSCU / PACS\SCU / PACS\SCP - -| v1 | v2 | Example | -|---|---|---| -| `echoscu($h, $p, $my, $rem)` | `(new EchoSCU(new Peer($h, $p, $rem), new Association($my)))->verify()` | [send_dcm.php](../examples/send_dcm.php) | -| `send_dcm($h, $p, $my, $rem)` | `(new SCU(new Peer($h, $p, $rem), new Association($my)))->send(File::open($f))` | [send_dcm.php](../examples/send_dcm.php) | -| `send_dcm(..., 1)` (batch) | `$scu->sendDirectory(dirname($f))` | [send_directory.php](../examples/send_directory.php) | -| `store_server($port, $dir, $handler, $cfg, $debug)` | `new SCP(port: $port, outputDirectory: $dir, postReceiveCommand: "$handler #p #f #c #a", presentationConfigFile: $cfg, debug: (bool) $debug, forkPerAssociation: true)` then `->start()` | [store_server.php](../examples/store_server.php) | - -Property and behavior changes: - -- **Error model:** `echoscu`/`send_dcm` returned `0` or an error string; `EchoSCU::verify()` and `SCU::send()` throw `PACS\Exception\NetworkException`. `store_server` blocked the process; `SCP::start()` returns an `SCPProcess` handle -- loop on `isRunning()` for a foreground server. -- `transfer_syntax` was **inert** in v1 (it set nothing on the wire). For real transfer-syntax negotiation pass a `PACS\TransferSyntaxProposal` (`automatic`, `uncompressed`, `implicitVRLittleEndian`, `jpegLossless`, `jpegBaseline`, `jpegExtended`) as the third `SCU` argument. -- The handler runs as a bare command with v1's placeholders appended: `#p #f #c #a` = storage dir, filename, **called** AE (the receiver), **calling** AE (the sender). It must be executable with a shebang. -- The shim's tunable properties (`echo_*_timeout`, `send_*_timeout`, `server_*_timeout`, `fork`, `disable_host_lookup`, `blocking`) map to constructor arguments in v2: `Association($callingAE, $acse, $dimse, $connection)` for echo/send, and `SCP(... acseTimeoutSeconds:, dimseTimeoutSeconds:, forkPerAssociation:, disableHostLookup:)` for the server. v2 blocking is just looping on `SCPProcess::isRunning()`. - -## Pointing at a non-PATH DCMTK - -v1 used the `TOOLKIT_DIR` constant. In v2, construct a `DCMTK\Toolkit` with the directory and pass it to any entry point (all accept an optional `Toolkit`): - -```php -$toolkit = new DCMTK\Toolkit('/opt/dcmtk/bin'); -$file = DICOM\File::open($path, $toolkit); -``` - -## Verifying a migration is complete - -Run your application with deprecation notices visible: - -```php -error_reporting(E_ALL); // includes E_DEPRECATED and E_USER_DEPRECATED -``` - -Every remaining shim call logs its v2 replacement. When a run produces no deprecation notices, the code is fully on the v2 API and the compatibility shim can be dropped. diff --git a/docs/phase6-shim-plan.md b/docs/phase6-shim-plan.md deleted file mode 100644 index 243322e..0000000 --- a/docs/phase6-shim-plan.md +++ /dev/null @@ -1,137 +0,0 @@ -# Phase 6 -- Backward-Compatibility Shim: Implementation Plan - -Companion to [`v2-rewrite-plan.md`](v2-rewrite-plan.md) (see its sections 8 and 9). The shim is newly authored, delegating code that re-exposes the frozen v1 public surface over the `DICOM\`/`PACS\` wrappers, emits deprecation notices, softens v2's stricter failures so an upgrade to `^2` cannot turn a working call fatal, removes the legacy `class_dicom.php`, and absorbs the three conversion entry points deferred from Phase 3. - -Everything here is reconstructed from clean-room-safe sources only: the reflected footprint (`v1-surface.json`), the capability map ([`v1-capability-map.md`](v1-capability-map.md)), the published README/`examples/`, and empirical blackbox observation of v1 (PHP loads and *runs* the legacy file; its source is never read). The legacy source stays closed (rewrite plan section 2). - ---- - -## 1. The frozen surface to re-expose - -From `v1-surface.json`, the v1 public footprint is **two global functions and three global classes**, all non-namespaced: - -- `Execute($command)` -- `is_dcm($file)` -- `dicom_convert` -- ctor `__construct($file = "")`; methods `dcm_to_jpg()`, `dcm_to_tn()`, `compress($new_file = "")`, `uncompress($new_file = "")`, `jpg_to_dcm($arr_info)`, `pdf_to_dcm($arr_info)`, `pdf_to_dcmcr($arr_info)`, `multiframe_to_video($format = "mp4", $framerate = 24, $temp_dir = "./video_temp")`; public properties `file`, `jpg_file`, `jpg_quality`, `temp_dir`, `template`, `tn_file`, `tn_size`. -- `dicom_net` -- methods `echoscu($host, $port, $my_ae = "DEANO", $remote_ae = "DEANO")`, `send_dcm($host, $port, $my_ae = "DEANO", $remote_ae = "DEANO", $send_batch = 0)`, `store_server($port, $dcm_dir, $handler_script, $config_file, $debug = 0)`; public properties `file`, `transfer_syntax`. -- `dicom_tag` -- ctor `__construct($file = "")`; methods `get_tag($group, $element)`, `load_tags()`, `write_tags($tag_arr)`; public properties `file`, `tags`. - -**Names are reproduced verbatim.** The shim keeps v1's snake_case identifiers, parameter names, and defaults exactly (`dicom_convert`, `jpg_to_dcm`, `$arr_info`, `"DEANO"`); the house `drinkingCamelCaseWithABBR` convention applies only to the shim's own internal helpers, never to the surface it must match. - ---- - -## 2. Delegation map (v1 -> v2) - -All array keys are 4-hex-digit `group`/`element`, decoded with `hexdec()`. Return values reproduce v1's observed shapes (section 8). - -| v1 surface | v2 delegation | Return / notes | -|---|---|---| -| `is_dcm($file)` | `DICOM\File::isDICOM((string) $file)` | `1` / `0` (int, not bool). | -| `Execute($command)` | deprecated passthrough (proc_open, capture stdout+stderr) | Returns combined output. No v2 replacement -- exec is intentionally outside the substrate. **Decision B.** | -| `dicom_convert::dcm_to_jpg()` | `(new Convert(File::open($this->file)))->toJPEG($out, Windowing::useWindow(1), (int) ($this->jpg_quality ?: 100))` | `$out` = `jpg_file` if set, else `"{file}.jpg"`. Returns `$out`. | -| `dicom_convert::dcm_to_tn()` | `->toThumbnail($out, (int) ($this->tn_size ?: 125), 75, Windowing::useWindow(1))` | `$out` = `tn_file` if set, else `"{file}_tn.jpg"`. Returns `$out`. | -| `dicom_convert::compress($new_file)` | `(new Compress(File::open($this->file)))->compress($out)` | `$out` = `$new_file` if given, else overwrite input. `dcmcjpeg` default = `Compression::losslessSV1()` (v1 parity). Returns `$out`. | -| `dicom_convert::uncompress($new_file)` | `->decompress($out)` | As above. Returns `$out`. | -| `dicom_convert::jpg_to_dcm($arr_info)` | `Convert::fromJpeg([$this->jpg_file], $out)` then apply `$arr_info` tags via `Dataset::put` | `$arr_info` = `"GGGG,EEEE" => value` tag map; `template` is inert. Returns `$out` path. | -| `dicom_convert::pdf_to_dcm($arr_info)` | `Convert::fromPdf($this->file, $out)` then apply `$arr_info` tags | **Improved, not validated compat** -- see Decision A. Returns `$out` path. | -| `dicom_convert::pdf_to_dcmcr($arr_info)` | deprecated alias of `pdf_to_dcm` | **Decision A.** | -| `dicom_convert::multiframe_to_video($format, $framerate, $temp_dir)` | `Convert::toJpegFrames($temp_dir/frame)` then ffmpeg assembly | Returns the video path. **Decision C resolved (section 4).** | -| `dicom_tag::get_tag($group, $element)` | `$this->dataset()->get(hexdec($group), hexdec($element))` in **name-rendering** mode | Returns value string with well-known UIDs as dictionary names (v1 fidelity -- section 5). | -| `dicom_tag::load_tags()` | `$this->dataset()->all()` (name-rendering) -> populate `$this->tags` | Returns `null`. `tags` keyed `"GGGG,EEEE" => value`. | -| `dicom_tag::write_tags($tag_arr)` | per entry: `->put(hexdec($g), hexdec($e), $value)` | `$tag_arr` = `"GGGG,EEEE" => value`. Falsy on success, error string on failure. | -| `dicom_net::echoscu($host, $port, $my_ae, $remote_ae)` | `(new EchoSCU(new Peer($host, (int) $port, $remote_ae), new Association($my_ae)))->verify()` | `0` on success, error string on failure. | -| `dicom_net::send_dcm($host, $port, $my_ae, $remote_ae, $send_batch)` | `new SCU(new Peer(...), new Association($my_ae), $proposal)` then `$send_batch ? sendDirectory(dirname($this->file)) : send(File::open($this->file))` | `transfer_syntax` -> proposal (default `automatic()`). Falsy on success, error string on failure. | -| `dicom_net::store_server($port, $dcm_dir, $handler_script, $config_file, $debug)` | `(new SCP((int) $port, $dcm_dir, postReceiveCommand: "php {$handler_script} #p #f #a #c", ...))->start()` then block | Synchronous (`--exec-sync`). `config_file`/`debug` -> Decision D. | - ---- - -## 3. The shim contract: deprecate, delegate, soften - -Every public entry follows one shape, centralized in a small internal helper so it is written once: - -1. **Deprecate.** Emit `E_USER_DEPRECATED` naming the v2 replacement (and an `@deprecated` docblock). One notice per call. -2. **Delegate.** Build the v2 objects from the arguments and the object's public properties; invoke the wrapper. -3. **Soften (rewrite plan section 9).** Wrap the delegation in a catch for `DICOM\Exception\ExceptionInterface` and `PACS\Exception\ExceptionInterface`; on a caught exception emit `E_USER_WARNING` naming the real problem and return the v1-shaped failure value rather than letting it propagate. The softening lives only in the shim -- the `DICOM\`/`PACS\` API stays fully fail-loud. Nothing is swallowed silently: the shim warns, it does not hide. - -Supporting rules: - -- **Public properties preserved and read at delegation time** (`jpg_quality`, `tn_size`, `temp_dir`, `transfer_syntax`, `file`, `tags`). `template` becomes a deprecated no-op property (`img2dcm` needs no XML template) so existing assignments do not error. -- **Lazy file handling.** Constructors keep `$file = ""` and store the path; the `DICOM\File` opens on demand, so `new dicom_convert()` does not fail. -- **No global-state mutation.** v1's `multiframe_to_video` `chdir`'d into its temp dir and never restored the process cwd (capability map). The shim never changes the working directory and uses absolute paths throughout. -- **Provenance.** SPDX header and the clean-room note on every shim file. - ---- - -## 4. The three deferred conversion entry points - -- **`jpg_to_dcm($arr_info)`** -> `Convert::fromJpeg([$this->jpg_file], $out)`, then the `$arr_info` tags applied via `Dataset::put`, returning `$out`. `img2dcm` mints the type-1 UIDs itself, so v1's `xml2dcm` SOPInstanceUID bug is impossible by construction. -- **`pdf_to_dcm` / `pdf_to_dcmcr`** -> `Convert::fromPdf`. **Improved functionality, explicitly not a validated compat reproduction** (Decision A). Both are documented in the shim and the migration guide as v2 behavior, not v1 fidelity. -- **`multiframe_to_video($format, $framerate, $temp_dir)`** -> `Convert::toJpegFrames("{temp_dir}/frame")` then ffmpeg. Verified invocation: `ffmpeg -y -framerate {framerate} -start_number 0 -i "{temp_dir}/frame.%d.jpg" -pix_fmt yuv420p {out}.{format}`. ffmpeg is a new, shim-only hard dependency: missing ffmpeg fails loud (then softens to the v1-shaped failure value) rather than silently degrading the way v1 does on current DCMTK. No `chdir`; the temp dir is created and removed explicitly; returns the video path. - ---- - -## 5. UID rendering as a first-class capability (Decision C) - -v1's `get_tag`/`load_tags` render well-known UID-valued tags as **dictionary names** -- `get_tag('0002','0010')` returns `'JPEGBaseline'`, `0002,0002` returns `'ComputedRadiographyImageStorage'` -- while unknown UIDs (e.g. an instance UID) stay numeric. This is `dcmdump`'s default rendering; v1 used no `-Un`. v2's `Dataset` reads with `-Un`, so it returns numeric UIDs. - -Strict v1 fidelity is required, and the new behavior is **elevated as a proper interface element, not cobbled into the shim**: a `UIDRendering` typed-choice in the `DICOM\` layer, consistent with the existing `Windowing`/`Scale`/`Compression` option objects. - -- `UIDRendering::numeric()` -- the current behavior (passes `-Un`); remains the `Dataset` default, so all existing v2 code and tests are unaffected. -- `UIDRendering::dictionaryNames()` -- omits `-Un`, letting `dcmdump` map well-known UIDs to names. This reuses `dcmdump`'s own UID dictionary, so fidelity is exact (well-known -> name, unknown -> numeric) without reimplementing the registry. - -The mode threads into the `Dataset` read; the shim's `dicom_tag` reads in `dictionaryNames()` mode. This lands first as a tested, documented core capability (checkpoint 6b) before the shim consumes it. - ---- - -## 6. Autoloading, legacy removal, provenance - -- **Autoload.** The two functions via Composer `"files"`; the three global classes via `"classmap"` (or `"files"`). They are deliberately non-namespaced, so PSR-4 does not apply. -- **Legacy removal.** The legacy `class_dicom.php` is deleted in this phase -- the shim replaces it, same public surface, newly authored bodies. A grep confirms nothing else references the old file before removal. -- **Migration map.** The section 2 table is the seed of the Phase 7 migration guide; the two are the same v1->v2 mapping and stay consistent by construction. The `pdf_to_dcm`/`pdf_to_dcmcr` rows carry the "improved, not validated compat" note (Decision A) into that guide. - ---- - -## 7. Testing (the section 9 behavioral check) - -- **Call compatibility.** Each v1 entry is callable with its v1 signature and produces the expected v2 effect (file written, tag read, object sent), validated against the pydicom oracle and real `storescp`/`storescu` peers (reusing the Phase 5 traits). -- **Deprecation.** Each call emits exactly one `E_USER_DEPRECATED`, captured via `set_error_handler`. -- **Softening.** A failure v2 is strict about but v1 tolerated produces an `E_USER_WARNING` and the v1-shaped value, not a throw. -- **UID rendering.** `UIDRendering::dictionaryNames()` reproduces the observed v1 strings (`'JPEGBaseline'`, well-known SOP class names) while unknown UIDs stay numeric; `numeric()` is unchanged. The shim's `get_tag`/`load_tags` match v1's blackbox output. -- **No cwd leak.** The process working directory is unchanged after `multiframe_to_video`. -- **Clean-room.** Tests are authored from the footprint and the published surface, never the legacy file. - ---- - -## 8. Decisions - -**Resolved by research** (README/`examples/` + blackbox + DCMTK/ffmpeg probes): - -- **Array shapes.** `"GGGG,EEEE" => value` for `write_tags`/`jpg_to_dcm`; `get_tag(group, element)` as two 4-hex strings; `tags` keyed `"GGGG,EEEE"`. -- **Output paths.** `dcm_to_jpg` -> `{file}.jpg`, `dcm_to_tn` -> `{file}_tn.jpg` (overridable via `jpg_file`/`tn_file`); `compress`/`uncompress` take an explicit output or overwrite. -- **Return shapes.** `is_dcm` -> `1`/`0`; `load_tags` -> `null` (fills `tags`); `get_tag` -> value string; conversions -> output path; `write_tags`/`send_dcm` -> falsy on success / error string on failure; `echoscu` -> `0` / error string; `multiframe_to_video` -> video path. -- **`send_batch`.** Truthy -> send every file in `dirname($this->file)` via `SCU::sendDirectory`; falsy -> single `send`. -- **`store_server`.** Blocking; handler invoked `php handler.php `, mapped to `storescp --exec-on-reception` (`-xcr`) with `--exec-sync` (`-xs`) and the `#p #f #a #c` placeholders (man-page placeholders; verified empirically in 6g). -- **ffmpeg.** Required; invocation in section 4 (tested). -- **`transfer_syntax`.** Undocumented/unused in examples; default `automatic()`, map known values if any surface. - -**Settled by the owner:** - -- **A -- `pdf_to_dcm` / `pdf_to_dcmcr`.** v1's versions are non-functional (no external tool, null return, no output -- blackbox confirms the capability map). v2 provides working Encapsulated PDF conversion via `Convert::fromPdf`, with `pdf_to_dcmcr` a deprecated alias. **Documented as improved v2 functionality, explicitly not a validated compatibility reproduction of v1** -- the shim docblocks and the migration guide state this plainly. -- **B -- `Execute($command)`.** Re-exposed as a deprecated passthrough that preserves v1's documented "shell execution wrapper (captures stdout + stderr)" behavior, but emits `E_USER_DEPRECATED` and is clearly marked a deprecated compatibility layer with no v2 replacement (exec is intentionally outside the substrate). -- **C -- Tag value rendering.** Strict v1 fidelity. Implemented via the elevated `UIDRendering` interface element (section 5), not a one-off shim hack. -- **D -- `store_server` `config_file`/`debug`.** Open sub-point: with v2's `+xa` accepting all supported transfer syntaxes, `config_file` is likely redundant. Proposed: accept and ignore both with a one-time `E_USER_WARNING`, or, if SOP-class restriction turns out to matter, elevate a config option on `PACS\SCP` (same "elevate, don't cobble" rule). Resolve at checkpoint 6g. - ---- - -## 9. Build sequence - -One CI-green logical checkpoint per commit on `claude`, in order: - -- **6a -- Scaffold + contract helper.** The deprecate/delegate/soften helper, `is_dcm`, `Execute` (Decision B), and the locked v1-shaped return values. -- **6b -- `UIDRendering` on `Dataset` (core).** The typed-choice and the `-Un` toggle, default `numeric()`; name-rendering tested against the observed v1 strings. The one v2-core addition this phase makes. -- **6c -- `dicom_tag`.** `get_tag`/`load_tags` (in `dictionaryNames()` mode), `write_tags`, the `file`/`tags` properties. -- **6d -- `dicom_convert` render + codec.** `dcm_to_jpg`, `dcm_to_tn`, `compress`, `uncompress`, and the render-related properties. -- **6e -- `dicom_convert` creation.** `jpg_to_dcm`, `pdf_to_dcm`, `pdf_to_dcmcr` (Decision A). -- **6f -- `dicom_convert` multiframe.** `multiframe_to_video` + the ffmpeg assembly (section 4). -- **6g -- `dicom_net`.** `echoscu`, `send_dcm`, `store_server` (Decision D; verify the `#p #f #a #c` placeholders), the `file`/`transfer_syntax` properties. -- **6h -- Wire-up + legacy removal.** Composer autoload entries, delete the legacy `class_dicom.php`, seed the Phase 7 migration map from the section 2 table. diff --git a/docs/v1-capability-map.md b/docs/v1-capability-map.md deleted file mode 100644 index c7b5e92..0000000 --- a/docs/v1-capability-map.md +++ /dev/null @@ -1,36 +0,0 @@ -# class_dicom.php v1 capability map - -For each v1 public operation, the external command-line tool(s) it invokes and the -argv observed when the operation runs. This is the second Phase 0.5 artifact (the first -is `v1-surface.json`, the reflected public surface). Together they fix -the v1 contract the v2 rewrite must reproduce. - -**Capture method.** Empirical. The committed research harness drives every public entry -point (`tools/research/exerciseV1Surface.php`) while logging shims installed in -`/usr/local/bin` (`tools/research/makeToolShims.sh`) intercept and record every external -tool call before delegating to the real binary in `/usr/bin`. The table below is built -from the resulting shim log; no legacy source was read. Tool calls are attributed to the -operation under whose `### OP:` marker they were logged. - -**DCMTK version.** `dcmdump v3.6.9 2024-12-11` (DCMTK 3.6.9). - -Paths are generalized to placeholders (``, ``, ...). Tool flags, and the -network parameters supplied by the harness (AE titles, host, port), are shown as observed. - -| Operation | Tool(s) | Observed argv | Notes | -|---|---|---|---| -| `is_dcm` | `dcmdump` | `-M +L +Qn ` | Probes any file to decide if it is DICOM. Per `dcmdump --help`: `-M` (`--load-short`) do not load very long values such as pixel data; `+L` (`--print-all`) print long tag values completely; `+Qn` (`--quote-nonascii`) quote non-ASCII and control chars as XML markup (note: this is *not* quiet mode — quiet is `-q`). Run on both a DICOM and a non-DICOM input. | -| `dicom_tag::load_tags` | `dcmdump` | `-M +L +Qn ` | Same dump invocation; logged 3x in one `load_tags` call. | -| `dicom_tag::get_tag` | `dcmdump` | `-M +L +Qn ` | Same dump; logged 2x. Tag value is parsed from the dump output. | -| `dicom_tag::write_tags` | `dcmodify` | `-i (0010,0010)= -i (0008,0080)= -nb ` | One `-i` per tag written; `-nb` = no backup file. Edits in place. | -| `dicom_convert::dcm_to_jpg` | `dcmj2pnm` | `+oj +Jq 100 --use-window 1 ` | JPEG output (`+oj`), quality 100, VOI window 1. | -| `dicom_convert::dcm_to_tn` | `dcmj2pnm` | `+oj +Jq 75 +Sxv 125 --use-window 1 ` | Thumbnail: scale to 125px (`+Sxv 125`), quality 75. | -| `dicom_convert::compress` | `dcmcjpeg` | ` ` | JPEG-compress the dataset. | -| `dicom_convert::uncompress` | `dcmdjpeg` | ` ` | Decompress the dataset. | -| `dicom_convert::jpg_to_dcm` | `xml2dcm` | ` ` | Only `xml2dcm` was intercepted (no `dcm2xml`): the XML is built from the bundled template (`jpg_to_dcm.xml`) and converted to DICOM. The known SOPInstanceUID issue is not visible at the argv level (it would live inside the generated XML); not observed in this capture. | -| `dicom_convert::pdf_to_dcm` | *(none observed)* | — | No external tool was intercepted under this operation. The op reported `[ok]`. The expected DCMTK tool (`pdf2dcm`) did not appear, so either the guess is wrong, the tool was not reached on this fixture, or it is invoked by a name/path outside the shimmed set. Flagged for follow-up. | -| `dicom_convert::pdf_to_dcmcr` | *(none observed)* | — | Same as `pdf_to_dcm`: no intercepted tool call. Flagged for follow-up. | -| `dicom_convert::multiframe_to_video` | `dcmj2pnm` (ffmpeg expected but not reached) | `+Fa +oj +Jq 100 frame` | Frame extraction: `+Fa` = all frames, written to a `video_temp` working dir as `frame.0.jpg` … `frame.N.jpg`. The op resolves `` relative to the class/repo location (`/opt/class_dicom/src/`), not the caller-supplied path. **ffmpeg argv not captured.** Observed facts only: all 8 frames were produced, the `ffmpeg` shim was present and first on `PATH`, `ffmpeg` was never invoked, and the op returned `[ok]`. The cause is undetermined without reading the legacy source (off-limits). This reads as a v1 behavior that silently degrades on the current DCMTK — the same family of issue as the `jpg_to_dcm` SOPInstanceUID problem (works only against an older toolkit's defaults). (Suspected, unconfirmed: a mismatch between the `frame.N.jpg` names `dcmj2pnm` writes and the input pattern the ffmpeg step expects.) The ffmpeg argv could not be surfaced by driving the public API; deferred to when the v2 multiframe path is built. **cwd footgun:** this op `chdir`'d into its temp dir (`video_temp`) and never restored the process cwd — the leaked cwd shows up in later operations (see `echoscu`/`send_dcm`, logged with `PWD=/tmp/v1cap/video_temp`). A v2 library method must not mutate global process cwd. | -| `dicom_net::echoscu` | `echoscu` | `-ta 5 -td 5 -to 5 -aet -aec ` | C-ECHO. Harness values: AE `CAP_AE`/`CAP_AE`, `127.0.0.1 11112`; v1 default AE is `DEANO`. ACSE/DIMSE/connection timeouts of 5s. | -| `dicom_net::send_dcm` | `dcmdump`, then `storescu` | `dcmdump -M +L +Qn ` then `storescu -ta 10 -td 10 -to 10 -aet -aec ` | Probes the file with `dcmdump` first, then C-STORE via `storescu`. Harness values as above; 10s timeouts. | -| `dicom_net::store_server` | `storescp` (not captured) | — | Not captured (Phase 5). Per the README this is a long-running C-STORE SCP: `storescp` + a storage directory + a post-receive handler script + a config file. | diff --git a/docs/v2-rewrite-plan.md b/docs/v2-rewrite-plan.md deleted file mode 100644 index 23d960f..0000000 --- a/docs/v2-rewrite-plan.md +++ /dev/null @@ -1,198 +0,0 @@ -# v2.0.0 -- Clean-Room Rewrite & Apache-2.0 Relicensing - -**Status:** In progress · roadmap priority #1. Phases 0-5 complete (substrate, detection, tags, conversion, compression, networking); Phase 6 (compatibility shim) and Phase 7 (docs & release) remain. -**Outcome:** a from-scratch, sole-authored, Apache-2.0 reimplementation that **faithfully replaces v1's public surface** with a clean, typed PHP wrapper over DCMTK. v2 does not implement DICOM semantics in PHP -- the format parsing, the pixel codecs, and the DIMSE protocol are DCMTK's; native PHP is limited to orchestration, validation, parsing DCMTK output, and API shaping. v2's job is to invoke DCMTK correctly to deliver exactly what v1 delivered, under a clean license and a modern design. Expanding beyond v1 to broader DCMTK coverage is deferred to **v3** (see §5), which keeps v2 narrow and gets the relicensed replacement out the door sooner. - ---- - -## 1. Why a rewrite and not a refactor - -This package began as a fork of Dean Vaughan's `class_dicom.php` (originally published ~2011 at deanvaughan.org, mirrored at `github.com/vedicveko/class_dicom.php`). That original was **published without any license**, which under default copyright means all rights are reserved by its author. The `"license": "MIT"` currently declared in this fork's `composer.json` is therefore not backed by a grant from the original author for the portions he wrote -- it asserts terms we are not in a position to grant. - -The current file is a **derivative work**. Refactoring it -- renaming methods, adding types, reorganizing internals -- produces another derivative work and carries the same defect forward. No amount of editing the existing source converts it into something we can license freely. - -The clean fix is to **stop deriving**. A ground-up reimplementation, written without reference to the legacy source, is original expression authored solely by this project's maintainer. That code can be licensed under Apache-2.0 cleanly, with no dependency on the original author's rights. - -Functionality is not the issue: DICOM operations, the use of DCMTK command-line tools, and a class's public capabilities are facts and ideas, not protected expression. We are free to build a library that *does the same things*. What we must not reuse is the original's **expression** -- its specific code, structure, and comments. - -> **Contacting Dean Vaughan is a dead path -- do not pursue it.** A prior PR to his repo went unanswered, and his GitHub profile is years dormant. That experiment has already been run. The rewrite does not depend on his cooperation and does not need it: clean-room reimplementation establishes our own clean Apache-2.0 footing outright. - ---- - -## 2. Clean-room discipline - -These rules exist to keep the provenance of v2 defensible. They are not optional. - -- **The legacy `class_dicom.php` is not opened, read, or referenced during v2 implementation.** Not for "just checking how it did X." The whole value of the exercise evaporates if the new code is written with the old code in view. -- **Implementation is written only from:** the DICOM standard (NEMA PS3, freely published) and the DCMTK tool documentation (`storescu`, `storescp`, `dcmdump`, `dcmodify`, `dcmj2pnm`, `img2dcm`, `dcmcjpeg`, `dcmdjpeg`, `echoscu`, `dcmftest`, `dcmconv`, etc.). These describe what DICOM requires and how each DCMTK tool is invoked. The research in §6 confirms the historical feature set v2 must preserve; it is not an authoring source for v2's command construction. -- **No expression carries over:** no copied lines, no transcribed method bodies, no replicated internal structure, no original comments. Public capability names dictated by the domain (e.g. "read tags", "send") are fine; mirroring the original's class shape is not. The observed call map is used only to confirm the historical feature set and compatibility expectations; v2's own DCMTK invocations are authored from DCMTK's documentation, never transcribed from the log. -- **Interface facts may be recovered; expression may not.** The v1 public surface (class/method/function names, signatures, properties, constants, visibility) is interface, recoverable via reflection and the README/examples (see §6). The method bodies, structure, and comments are expression and stay off-limits. Observing v1's external behavior as a black box -- its outputs, and the DCMTK tools it invokes -- is observation, not reading expression. -- **The v2 API is designed fresh.** The original is a single god-class; v2 is decomposed into a typed wrapper over DCMTK (see §4). A genuinely new design both improves the library and reinforces the separation. -- **The compatibility shim is held to the same discipline.** v2 ships a shim that re-exposes the v1 public surface (see §9); its class name and member signatures are reconstructed from the reflection footprint, the README, the `examples/`, and the v1->v2 migration map -- never from the legacy file. Re-exposing a public surface reuses interface facts, not protected expression; the shim's bodies are newly authored delegations into the new classes. -- **Provenance is recorded in the work itself:** the first v2 commit states that it is an independent clean-room implementation, and the NOTICE file credits Dean Vaughan's original as the conceptual predecessor while asserting the new code is independently authored. - -If at any point the right move seems to be "look at the old file to settle a detail," the answer is to consult the DICOM/DCMTK documentation instead. The standard and the tool docs are the source of truth, not the legacy code. - ---- - -## 3. Licensing & provenance artifacts - -Added in Phase 0, before any implementation code exists: - -- **`LICENSE`** -- the full Apache License 2.0 text, verbatim. -- **`NOTICE`** -- Apache convention. Copyright line for the maintainer; a short provenance note crediting Dean Vaughan's original `class_dicom.php` as the inspiration for the project's capabilities, and stating that the v2 code is an independent reimplementation. -- **SPDX headers** on every source file: `// SPDX-License-Identifier: Apache-2.0` plus a one-line copyright. Headers are added as files are created, never retrofitted in bulk. -- **`composer.json`** -- change `"license"` from `"MIT"` to `"Apache-2.0"`; correct the `authors` block to reflect the v2 maintainer and the project's role-based attribution convention. - -The v1 line keeps its existing `composer.json` as-is on the default branch (see §9). The relicense applies to v2, which is new code. - ---- - -## 4. Target architecture - -Modern PHP, no manual configuration, loud failure. v2 is a typed PHP wrapper over the DCMTK command-line toolset. - -- **Wrapper-first by design.** Every DICOM operation is a validated invocation of the appropriate DCMTK tool. v2 does not implement DICOM semantics natively: the format parsing, the pixel codecs, and the DIMSE protocol are DCMTK's, and v2 wraps them rather than re-deriving them. Native PHP is where all the orchestration lives -- binary discovery, process execution, argument escaping, command-result objects, `dcmdump` output parsing, tag-value normalization, file-path handling, exceptions, the compat-shim delegation, and any template generation a tool needs (e.g. the XML for `xml2dcm`). The rule is precise: PHP never re-implements something DCMTK already does. -- **DCMTK is a hard runtime requirement.** This matches v1, whose README already requires DCMTK across the board. Its absence fails loud with a descriptive exception. We accept the supply-chain dependency deliberately: the correctness of DICOM operations rides on a 30-year-maintained implementation rather than on hand-rolled PHP. -- **v2's scope is exactly v1's capability set.** v2 faithfully replaces what v1 did -- no more -- under a clean license and a modern design. The broader ambition of a full PHP interface to the DCMTK toolset is real but deliberately deferred to **v3**, so the relicensed replacement ships without the scope growing unmanageable. v1 parity is what the shim guarantees. -- **PSR-4 autoloading** under peer top-level namespaces, `src/` layout, Composer-loadable with no classmap. Namespaces are uppercased per the project's acronym convention: `DICOM\`, `PACS\`, `DCMTK\` -- not `Dicom\`. -- **Decomposed into a substrate plus typed wrappers -- not a god-class.** The v1 god-class conflated file/image operations with network operations and buried the DCMTK exec inside. v2 separates them: - - `DCMTK\` -- the substrate every wrapper sits on. `DCMTK\Toolkit` handles binary discovery, version detection, argument construction, validated exec (the synchronous `run`), and output parsing; Phase 5 added a background-process sibling (`start`, returning a `DCMTK\Process` handle) for the long-running `storescp` receiver. It is the single place that knows how to call DCMTK safely. - - `DICOM\` -- file and tag concerns as typed wrappers over the toolkit, following DICOM's own File/Dataset distinction (a Part 10 file contains a data set): - - `DICOM\File` -- the Part 10 file: detection (`dcmftest`), the file-meta accessors (`transferSyntaxUID`, `mediaStorageSOPClassUID`), and the typed tag API (`getDate(Tag)`, `setPersonName(Tag, ...)`, ...), each gated on the tag's value representation so a wrong-type access fails loud before any tool runs. It owns the file's `Dataset`. - - `DICOM\Dataset` -- the data-element collection: the raw, string-valued read/write surface (`get`/`all` over a single cached `dcmdump`, `put` via `dcmodify` with cache invalidation). This is the low-level route and the compat-shim's delegate; all the tool plumbing lives here. - - `DICOM\Tag` / `DICOM\TagInfo` / `DICOM\Value\*` -- the typed tag vocabulary. `Tag` is an enum of every standard data element, generated from the DCMTK data dictionary (`tools/codegen/`, vendored `dicom.dic`), so a consumer reaches a tag by its standard keyword (`Tag::PatientName`) and reads/writes a typed value (`DICOM\Value\Date`, `Time`, `DateTime`, `PersonName`, `UID`; integer/decimal/text stay native PHP). The VR-to-PHP-type families are authored once; the per-tag data is generated, so it cannot drift from what the tools use. - - `DICOM\Tool` -- the shared DICOM/DCMTK boundary: runs a tool translating substrate failures to `ToolkitException`, and asserts readability as `IOException`, so detection and element access both surface only `DICOM\Exception\ExceptionInterface`. - - `DICOM\Convert` (image conversion) and `DICOM\Compress` (JPEG codec conversion) -- delivered in Phases 3-4, each paired with per-tool typed-choice option objects (`Windowing`, `Scale`, `SOPClass`, `StudySeriesSource`, `Compression`) that model a tool's mutually-exclusive flag groups. - - Each method builds a DCMTK invocation, runs it through `DCMTK\Toolkit`, and parses the result into typed PHP. - - `PACS\` -- DICOM networking as a first-class peer, delivered in Phase 5: `PACS\SCU` (C-STORE send), `PACS\SCP` (C-STORE receive + per-reception command hook), `PACS\EchoSCU` (C-ECHO; named `EchoSCU` because `Echo` is a reserved word), each wrapping the corresponding DCMTK network tool over a shared connection vocabulary (`Peer`, `Association`, `TransferSyntaxProposal`). Class names carry the acronym casing per the project convention (`SCU`, not `Scu`). - - Exact class boundaries settle during design. -- **Typed throughout.** Typed, visibility-scoped properties; parameter and return types on all public methods; PHP 8.5+ baseline (the latest stable branch and the recommended floor for new code; 8.4 and earlier are not targeted). -- **Exceptions, never silent returns.** A shared exception hierarchy: a marker interface (so callers can catch broadly) with typed concretes covering "DCMTK binary not found," "DCMTK invocation failed" (non-zero exit), and "DCMTK produced unexpected output." Every failure path throws something descriptive. This matches the project's fail-loud principle: a crash during development is a gift; a silent wrong answer in production is a disaster. It also directly addresses v1's silent-failure habit. -- **Validated exec.** The exec helper validates the binary exists before invoking, checks the exit status, and confirms the output matches what the call was supposed to produce; anything unexpected throws. v2 never accepts a bad or empty result as success. -- **`DCMTK\Toolkit` returns a value object, not raw strings.** Every invocation yields a typed, immutable `CommandResult` carrying the binary, argv, exit code, stdout, stderr, and an optional duration. Wrappers parse and validate that object; raw shell output never leaks past the toolkit boundary. This buys testability, precise error messages, structured logging, and debuggability without spreading shell internals through the codebase. - -```php -final readonly class CommandResult -{ - public function __construct( - public string $binary, - public array $argv, - public int $exitCode, - public string $stdout, - public string $stderr, - public ?float $durationSeconds = null, - ) {} -} -``` -- **No global configuration, no `define()`.** DCMTK's location is discovered at the point of use (constructor injection, env var, or PATH), never set as a global. This resolves the hardcoded-`TOOLKIT_DIR`/symlink friction in v1. - ---- - -## 5. Capability scope - -v2 faithfully wraps DCMTK's functionality. The correctness of each underlying DICOM operation is DCMTK's responsibility; v2 owns the correctness of the **invocation** -- the right tool, the right arguments, and an output that matches the intended result. The capability set is exactly v1's; broader DCMTK coverage is v3. v2's tool selection and argument construction are authored from DCMTK's documentation; the call-map research in §6 confirms only the historical feature set and compatibility expectations. DCMTK coverage gaps are recorded there too. - -v1-parity capabilities and the DCMTK tool that provides each (tool choice and arguments authored from DCMTK docs; the historical feature set confirmed against the call map): - -- **Detection / metadata** (`is_dcm`, transfer syntax, read tags) -- `dcmftest` for the Part 10 check, `dcmdump` for transfer syntax and tag values. (v1 itself ran `dcmdump -M +L +Qn` for the `is_dcm` check, not `dcmftest`; `dcmftest` vs `dcmdump` for v2 detection is a Phase 1 choice, not a parity requirement.) -- **Tag modify / insert** -- `dcmodify`. -- **Compression / decompression** (`compress`, `uncompress`) -- `dcmcjpeg` / `dcmdjpeg`; JPEG 2000 only where the DCMTK build includes the module (a recorded gap if not). -- **DICOM -> JPEG / thumbnails / window-level** -- `dcmj2pnm` with the appropriate windowing and scaling flags. -- **JPEG -> DICOM** (`jpg_to_dcm`) -- `img2dcm` (chosen and delivered in Phase 3). v1 instead filled a template and ran `xml2dcm` -- the path where its SOPInstanceUID bug lives; `img2dcm` makes that moot, minting the type-1 UIDs itself, so the bug cannot occur. -- **PDF -> DICOM** (`pdf_to_dcm`, `pdf_to_dcmcr`) -- `pdf2dcm` (confirmed and delivered in Phase 3 as `Convert::fromPdf`, producing Encapsulated PDF Storage). v1's original tool was never observed; the v1 `pdf_to_dcm`/`pdf_to_dcmcr` naming is reconciled in the Phase 6 shim. -- **Multi-frame -> video** -- frame extraction delivered in Phase 3 as `Convert::toJpegFrames` (`dcmj2pnm +Fa`); video encoding is outside DCMTK (it has no consumer-video encoder), confirming the recorded gap, so the ffmpeg assembly step is deferred to the Phase 6 shim/caller. -- **C-ECHO** -- `echoscu`. -- **C-STORE SCU** (`send_dcm`) -- `storescu`. -- **C-STORE SCP** (`store_server`) + post-receive handler hook -- `storescp`. - -The committed Phase 1 detection tests (`isDICOM` returns the right bool, `transferSyntaxUID` the right UID) are implementation-agnostic and hold unchanged: they are now satisfied by wrapping `dcmftest`/`dcmdump` rather than by native parsing. - -This capability set is reconciled against the frozen capability map (`v1-capability-map.md`). Standalone transfer-syntax conversion (`dcmconv`) was dropped from v1 parity -- no v1 method performs it and the capture never invoked it -- and is deferred to v3. Where v2's planned tool differs from what v1 actually ran (`dcmftest` vs v1's `dcmdump` for detection; `img2dcm` vs v1's `xml2dcm` for JPEG->DICOM), v1's mechanism is recorded but does not bind v2; the final tool is a Phase 1/3 design choice. - -Coverage beyond v1 (additional DCMTK tools, C-FIND/C-MOVE, TLS, DICOMDIR, SR, worklist, etc.) is **v3**, staged in `ROADMAP.md`. v2 ships exactly the v1-parity set wrapped (see §8) and nothing more; v2.x is maintenance of that wrapper, not feature growth. - ---- - -## 6. Research methodology - -Two clean-room-safe research artifacts establish the surface to preserve and how each capability maps to DCMTK. Both observe interface and behavior only; neither reads the legacy source. The execution environment is a separate planning item -- reflection needs a PHP runtime that can load the class, the harness needs PHP plus DCMTK -- and is not this sandbox. We will settle the where and the sequencing as its own step. - -- **Public-surface footprint via reflection.** `ReflectionClass` over every v1 class and `ReflectionFunction` over its standalone functions (`is_dcm`, `Execute`), capturing the complete public footprint: method names, parameters (names, types, defaults), return types, properties, constants, visibility, static-vs-instance, and inheritance. The output is a machine-readable inventory that defines exactly what the compat shim must re-expose -- authoritative and exhaustive, where the README and examples are only illustrative. Reflection extracts interface facts (the surface), never bodies or logic, so it is clean-room-safe. It loads the class to introspect it; it does not execute the methods and does not require DCMTK. Loading is done in isolation, capturing only the reflection output, never the file contents. -- **Capability / call map via logging shims.** Wrapper scripts named exactly like the DCMTK tools are placed at `TOOLKIT_DIR`'s default location, with the real binaries relocated to a sibling directory the shims exec by absolute path (so the legacy file is never edited). Each shim logs tool name, argv, and cwd as JSONL, then execs the real tool. Driving each v1 public operation once against fixtures records which operations v1 supports and how it exercised DCMTK. Its purpose is to **verify the historical feature set and identify compatibility expectations** -- what v2 must continue to provide -- not to dictate how v2 calls DCMTK. v2's command construction is authored from DCMTK's documentation. The networking operations need a DICOM peer, so the runner spins a real `storescp` locally as the target. Needs PHP + DCMTK. -- **pydicom as a check, not a truth.** Outputs are cross-checked against pydicom to catch silent v1 failures and, later, to confirm v2's invocations produce the expected result. Agreement is confidence; disagreement is a flag adjudicated against the standard, never auto-resolved. Metadata and detection checks can be exact; lossy/codec checks use decoded-pixel tolerance or structural properties (valid encoding, dimensions, photometric interpretation), and the artifact says so rather than claiming a precision it lacks. pydicom is itself just an implementation, with its own bugs and its own leniency, so it is never treated as the standard -- only as a tripwire that points at cases worth checking against NEMA PS3. - -This research is **Phase 0.5** (see §8). Its outputs are committed as `docs/v1-surface.json` (the frozen reflection footprint) and `docs/v1-capability-map.md` (each capability, its DCMTK tool, and any DCMTK gaps): a frozen compatibility target produced before any wrapper code, so implementation aims at a fixed v1 surface rather than an ambiguous moving one. - ---- - -## 7. DCMTK bug policy - -DCMTK owns the correctness of the underlying operations, and the default is to call each tool correctly and trust its output. Where a DCMTK tool genuinely misbehaves, v2 does not silently paper over it. Each issue is recorded in `docs/dcmtk-workarounds.md` with: - -- a minimal reproduction, -- the workaround applied in v2 and why it is necessary, -- an upstream-fix note: a TODO until filed, then a link to the DCMTK issue or PR. - -Workarounds are the documented exception, not a routine. Anything that looks like a recurring need to compensate for DCMTK is a signal to re-examine whether we are calling the tool correctly first. - ---- - -## 8. Phased delivery - -One logical checkpoint per phase; commit per checkpoint on the `claude` branch. v2 covers exactly the v1-parity capability families wrapped; broader DCMTK coverage is deferred to v3. - -- **Phase 0 -- License & scaffold.** (done) `LICENSE`, `NOTICE`, SPDX header convention, `composer.json` relicense + PSR-4 autoload, `src/` skeleton, CI workflow. No behavior. -- **Phase 0.5 -- Public surface & capability inventory.** (done) Run the §6 research: reflection produces `docs/v1-surface.json` (the complete, frozen v1 public footprint) and the capability map produces `docs/v1-capability-map.md` (each v1 capability, its DCMTK tool, and any DCMTK gaps). This inventory is the frozen definition of v1 parity, committed before any wrapper code so implementation targets a fixed surface and does not overbuild. No library code. -- **Phase 1 -- DCMTK substrate + detection.** (done) `DCMTK\Toolkit` (discovery, version detection, argument construction, validated exec, output parsing) and the exception hierarchy, plus the first wrapped capability: detection/metadata (`DICOM\File` over `dcmftest`/`dcmdump`). Satisfies the committed detection tests. DCMTK is required from here on. -- **Phase 2 -- Tags.** (done) A `DICOM\Tag` enum generated from the DCMTK data dictionary (every standard element reachable by its keyword) plus a typed value class per VR; the raw, string-valued read/write surface on `DICOM\Dataset` (`dcmdump` read, `dcmodify` write, cache-invalidating) -- the parity/shim floor; and VR-gated typed accessors on `DICOM\File` (`getDate(Tag)`, `setPersonName(Tag, ...)`) that fail loud on a wrong-type access and elevate a malformed stored value to `InvalidDICOMException`. Tested against fixtures with the pydicom oracle. -- **Phase 3 -- Conversion.** (done) `DICOM\Convert`: DICOM -> JPEG and thumbnails (`dcmj2pnm`) with `Windowing` and `Scale` typed choices; JPEG -> DICOM via `img2dcm` (chosen over v1's `xml2dcm`, which it makes moot by minting the type-1 UIDs itself) with `SOPClass` (single- vs multi-frame Secondary Capture) and `StudySeriesSource` (fresh vs inherited study/series) typed choices; PDF -> DICOM via `pdf2dcm`; and frame extraction (`dcmj2pnm +Fa`). Shared `ConversionException`. The `jpg_to_dcm` shortcut, the `pdf_to_dcm`/`pdf_to_dcmcr` naming, and `multiframe_to_video`'s ffmpeg assembly are deferred to the Phase 6 shim. -- **Phase 4 -- Compression.** (done) `DICOM\Compress`: `compress`/`decompress` via `dcmcjpeg`/`dcmdjpeg`, with a `Compression` typed choice (lossless SV1 [default, v1 parity], lossless, baseline, extended; retired processes excluded). JPEG 2000 is absent from this DCMTK build (`dcmj2kc`/`dcmj2kd` not installed) and recorded as a gap. (Standalone transfer-syntax conversion via `dcmconv` remains v3, not v1 parity.) -- **Phase 5 -- Networking (`PACS\`).** (done) `PACS\EchoSCU` (C-ECHO, `echoscu`), `PACS\SCU` (C-STORE send, `storescu`; files, directories, recursive), and `PACS\SCP` (C-STORE receive, `storescp`, + per-reception command hook and fork-per-association), over a shared `Peer`/`Association`/`TransferSyntaxProposal` vocabulary. The blocking receiver runs as a managed background process, which added the `DCMTK\Process` substrate (`Toolkit::start`/`Tool::spawn`, the async siblings of `run`). Tests run against real `storescp`/`storescu` peers on loopback. -- **Phase 6 -- Backward-compatibility shim.** (next) A newly authored global class preserving the v1 public surface (from the reflection footprint), delegating into the `DICOM\`/`PACS\` wrappers and emitting `E_USER_DEPRECATED`; it preserves call compatibility, not bug compatibility (see §9). This phase also picks up the v1-named conversion entry points deferred from Phase 3 -- `jpg_to_dcm`, the `pdf_to_dcm`/`pdf_to_dcmcr` split, and `multiframe_to_video`'s ffmpeg assembly over `Convert::toJpegFrames`. Autoloaded via Composer `classmap`/`files`. The legacy `class_dicom.php` is removed in this phase -- the shim replaces it. -- **Phase 7 -- Docs & release.** README rewrite, migration guide (§9), conformance notes, tag `v2.0.0`. - ---- - -## 9. Compatibility & migration - -This is the maintainer's only library with real external consumers via Packagist, so v2 ships a managed deprecation path rather than a hard break. - -- **v1 stays put.** The current code remains on the default branch (`main`) for existing `^1` consumers; it is not deleted there and not retroactively relicensed. `v1.1.0` is already tagged, so consumers have a stable pin. -- **v2 introduces a new API** under the `DICOM\` and `PACS\` namespaces. It is not a refactor of the old surface -- it is the fresh, decomposed wrapper design in §4. -- **A backward-compatibility shim ships with v2.** A newly authored class preserves the v1 public surface (the original global class name and member signatures, taken from the reflection footprint), delegating into the new `DICOM\`/`PACS\` wrappers and emitting `E_USER_DEPRECATED` plus `@deprecated` docblocks that name the replacement call. Existing call sites keep working unchanged, then migrate incrementally guided by the warnings. -- **The shim preserves public call compatibility, not bug compatibility.** It keeps the old class name, method signatures, and call patterns working; it does not reproduce v1's defects -- the silent failures, the unsafe path handling, the incidental parsing quirks. Calls run through v2's correct, loud behavior, so a v1 defect is surfaced rather than silently reproduced. That behavior change is intentional and is what a major-version boundary is for; the only v1 quirks worth recreating are ones a real consumer is found to depend on, handled case by case. -- **Surface, don't crash: warn where v1 would have kept running.** Upgrading to `^2` must not turn a working production call into a fatal one. Where v2 is stricter than v1 -- a call that returned non-fatally under v1 now reaches v2's fail-loud path -- the shim catches the v2 exception, emits an `E_USER_WARNING` (or `E_USER_DEPRECATED`) naming the real problem, and returns a v1-shaped value rather than letting the exception propagate. The failure is visible in logs and error handlers without killing the app, so deprecated code can see and fix it on its own schedule. This softening is the shim's alone: the underlying `DICOM\`/`PACS\` API stays fully fail-loud and throws. Nothing is swallowed silently -- the shim warns, it does not hide -- and genuine failures that v1 itself errored on are surfaced the same way. -- **The shim does not reintroduce the licensing defect.** v2 must not ship the legacy `class_dicom.php` itself -- that file is the unlicensed expression. The shim *replaces* it: same public surface, newly authored delegating bodies, surface reconstructed from the reflection footprint and README/examples/migration map (never the legacy file, per §2). It autoloads via Composer `classmap`/`files`, since it is deliberately a global, non-namespaced class. -- **Deprecation lifecycle (SemVer).** `^2` = new API plus the working-but-noisy shim. `^3` is the post-v1 line: it drops the deprecated shim (the warnings have given consumers a full major-version window to migrate) and is where coverage expands beyond v1 toward the broader DCMTK toolset (§5). -- **Migration guide** (Phase 7) maps each v1 call to its v2 equivalent. It is the same v1->v2 mapping the shim implements, so the two stay consistent by construction. -- **Packagist:** the v2 release publishes under the Apache-2.0 license field; the package description keeps its honest origin note. - ---- - -## 10. Testing - -Per the project's testing standards -- tests verify real behavior, never monkeypatch the unit under test, and are never satisfied by contorting the code: - -- **The validated layer is the invocation, not DCMTK's conformance.** v2's job is to call the right tool with the right arguments and get the intended result; tests assert exactly that. They do not try to re-certify DCMTK. -- **pydicom as a check, not a truth** (per §6). Tag, conversion, compression, and networking outputs are cross-checked against pydicom/pynetdicom; agreement is confidence, disagreement is investigated against the standard. Metadata exact, codec by tolerance/structure. -- **Real DCMTK from Phase 1.** Everything is DCMTK-backed, so tests run against an actual DCMTK install from the first wrapped operation onward -- no mocking the toolkit boundary's logic. Test doubles are acceptable only for genuinely external boundaries (e.g. an unreachable network host), not for the behavior under test. -- **Error paths are first-class.** Missing binary, non-zero exit, unexpected/empty output, missing files, invalid DICOM, unreachable hosts, malformed tags -- each asserts the specific exception, not just "it didn't crash." -- **Fixture breadth.** Multiple transfer syntaxes (Implicit VR, Explicit VR, JPEG Baseline, JPEG Lossless) and modalities (CR, CT, MR, US, multi-frame). -- **CI matrix.** GitHub Actions on PHP 8.5, on every push, with DCMTK installed from Phase 1. Add 8.6 to the matrix when it releases (~Nov 2026). - ---- - -## 11. Done criteria - -v2.0.0 is done when: - -1. Every v1-parity capability in §5 works as a validated DCMTK invocation, with a descriptive exception on every failure path (binary missing, invocation failed, unexpected output). -2. The CI matrix is green on the PHP version range with DCMTK installed. -3. `LICENSE`, `NOTICE`, and SPDX headers are present and consistent; `composer.json` declares Apache-2.0. -4. No file references, reproduces, or derives from the legacy `class_dicom.php` -- the new classes and the compat shim trace entirely to the DICOM standard, DCMTK docs, the reflection footprint, the published v1 surface (README/examples), and this plan. -5. The backward-compatibility shim preserves the v1 public call surface (matching the reflection footprint) without recreating v1's defects, delegates into the new wrappers, and emits deprecation warnings -- validated against the behavioral check. -6. The migration guide is published; `v1.1.0` is tagged on the default branch and the legacy file is removed from the v2 line. -7. DCMTK is required and discovered at point of use, failing loud when absent; any DCMTK workarounds are recorded in `docs/dcmtk-workarounds.md` with upstream-fix notes. diff --git a/examples/compress.php b/examples/compress.php index 2600a6e..2836508 100755 --- a/examples/compress.php +++ b/examples/compress.php @@ -2,16 +2,10 @@ file = $file; $d->load_tags(); - * $ts = $d->get_tag('0002', '0010'); - * $c = new dicom_convert; $c->file = $file; $c->compress('compressed.dcm'); - * - * After (v2-native): DICOM\Compress, and File::transferSyntaxUID() instead of a raw - * address. The default mode is lossless SV1, matching v1. + * Uses DICOM\Compress and File::transferSyntaxUID(). The default mode is + * lossless SV1. */ declare(strict_types=1); diff --git a/examples/dcm_to_jpg.php b/examples/dcm_to_jpg.php index 4bb7c0d..3dbf5ab 100755 --- a/examples/dcm_to_jpg.php +++ b/examples/dcm_to_jpg.php @@ -2,14 +2,9 @@ file = $file; - * $d->dcm_to_jpg(); // wrote $file.jpg - * $d->dcm_to_tn(); // wrote $file_tn.jpg - * - * After (v2-native): DICOM\Convert -- one object, explicit output paths, no shim. + * Uses DICOM\Convert: one object, with explicit output paths. */ declare(strict_types=1); @@ -27,10 +22,10 @@ $base = sys_get_temp_dir() . '/' . pathinfo($path, PATHINFO_FILENAME); $convert = new Convert(File::open($path)); -$convert->toJPEG($base . '.jpg'); // replaces dcm_to_jpg() -$convert->toThumbnail($base . '_tn.jpg'); // replaces dcm_to_tn() +$convert->toJPEG($base . '.jpg'); +$convert->toThumbnail($base . '_tn.jpg'); echo "Wrote {$base}.jpg and {$base}_tn.jpg\n"; -// Windowing defaults to the first stored VOI window (as in v1); pass a +// Windowing defaults to the first stored VOI window; pass a // DICOM\Windowing or DICOM\Scale to toJPEG() to choose another. diff --git a/examples/get_tags.php b/examples/get_tags.php index 591c5db..a416e59 100755 --- a/examples/get_tags.php +++ b/examples/get_tags.php @@ -2,17 +2,10 @@ load_tags(); - * $name = $d->get_tag('0010', '0010'); // string, by address - * - * After (v2-native): named, typed, validated accessors keyed by the Tag enum. The - * raw-address style was only ever a workaround for their absence; in v2 prefer the - * typed accessors and fall back to the raw dataset only for tags without one. + * Named, typed, validated accessors keyed by the Tag enum. Prefer the typed + * accessors and fall back to the raw dataset only for tags without one. */ declare(strict_types=1); @@ -39,8 +32,8 @@ echo 'SOP UID: ' . $file->getUID(Tag::SOPInstanceUID)?->value . "\n"; // Need every tag (e.g. to dump the header)? The full map is still there, -// keyed "gggg,eeee" (replaces v1's load_tags() + $d->tags): +// keyed "gggg,eeee": print_r($file->dataset()->all()); -// Only for a tag with no typed accessor, the raw address still works -// (replaces get_tag('GGGG', 'EEEE')): $file->dataset()->get(0x0028, 0x0010); +// Only for a tag with no typed accessor, the raw address still works: +// $file->dataset()->get(0x0028, 0x0010); diff --git a/examples/get_tags_webbased.php b/examples/get_tags_webbased.php index ea965ba..9d07e05 100644 --- a/examples/get_tags_webbased.php +++ b/examples/get_tags_webbased.php @@ -1,10 +1,9 @@ load_tags(); $d->get_tag('0010', '0010'); - * After (v2-native): typed accessors on DICOM\File, plus the full map when needed. + * Typed accessors on DICOM\File, plus the full map when needed. */ declare(strict_types=1); diff --git a/examples/jpg_to_dcm.php b/examples/jpg_to_dcm.php index e13c372..838f674 100755 --- a/examples/jpg_to_dcm.php +++ b/examples/jpg_to_dcm.php @@ -2,19 +2,11 @@ jpg_file = 'test.jpg'; $d->template = 'jpg_to_dcm.xml'; $d->temp_dir = 'dcm_temp'; - * $dcm = $d->jpg_to_dcm(['0010,0010' => 'VAUGHAN^DEAN', '0020,000d' => '...', ...]); - * - * After (v2-native): Convert::fromJpeg() builds the Secondary Capture object and - * GENERATES the study/series/SOP UIDs (StudySeriesSource::generate()), then typed - * setters fill the identifying tags. No template and no hand-built UIDs -- the old - * jpg_to_dcm.xml is obsolete. + * Convert::fromJpeg() builds the Secondary Capture object and generates the + * study/series/SOP UIDs (StudySeriesSource::generate()), then typed setters + * fill the identifying tags. */ declare(strict_types=1); diff --git a/examples/send_dcm.php b/examples/send_dcm.php index 6afae18..f5a6170 100755 --- a/examples/send_dcm.php +++ b/examples/send_dcm.php @@ -2,16 +2,10 @@ file = $file; - * $out = $d->send_dcm('localhost', '104', 'DEANO', 'example'); // 0 ok, error string on fail - * // $d->transfer_syntax was accepted but did nothing. - * - * After (v2-native): a Peer + Association drive PACS\SCU, which throws on failure. - * The real payoff: a TransferSyntaxProposal actually controls negotiation, where - * v1's transfer_syntax property was inert. + * A Peer + Association drive PACS\SCU, which throws on failure. A + * TransferSyntaxProposal controls negotiation. */ declare(strict_types=1); @@ -35,7 +29,7 @@ $peer = new Peer($host, $port, 'example'); // host, port, called AE $association = new Association('DEANO'); // calling AE -// To actually negotiate a transfer syntax (v1 could not), pass a proposal: +// To negotiate a specific transfer syntax, pass a proposal: // new SCU($peer, $association, PACS\TransferSyntaxProposal::jpegLossless()); $scu = new SCU($peer, $association); diff --git a/examples/send_directory.php b/examples/send_directory.php index 7ca5f27..18b2dcf 100644 --- a/examples/send_directory.php +++ b/examples/send_directory.php @@ -2,14 +2,11 @@ sendDirectory($dir) sends the whole directory in one association. + * PACS\SCU::send() per file (one Peer/Association reused), moving each file on + * success. For a one-shot bulk send with no per-file tracking, + * $scu->sendDirectory($dir) sends the whole directory in one association. */ declare(strict_types=1); diff --git a/examples/store_server.php b/examples/store_server.php index ef6cb1e..aa681f0 100755 --- a/examples/store_server.php +++ b/examples/store_server.php @@ -2,17 +2,12 @@ store_server(104, './dcm_temp', './store_server_handler.php', - * 'store_server_config.cfg', 1); // blocking - * - * After (v2-native): PACS\SCP, configured explicitly. start() returns a handle; the - * loop reproduces v1's foreground (blocking) server. The handler runs as a bare - * command with v1's #p #f #c #a placeholders appended (so it must be executable). + * PACS\SCP, configured explicitly. start() returns a handle; the loop runs a + * foreground (blocking) server. The handler runs as a bare command with the + * #p #f #c #a placeholders appended (so it must be executable). */ declare(strict_types=1); diff --git a/examples/store_server_handler.php b/examples/store_server_handler.php index 46e9b71..0ed72ba 100755 --- a/examples/store_server_handler.php +++ b/examples/store_server_handler.php @@ -2,14 +2,13 @@ get_tag('0010', '0010'); - * After (v2-native): DICOM\File typed accessor. + * Uses a DICOM\File typed accessor. */ declare(strict_types=1); diff --git a/examples/uncompress.php b/examples/uncompress.php index fa78e73..34bfe72 100755 --- a/examples/uncompress.php +++ b/examples/uncompress.php @@ -2,12 +2,9 @@ file = $file; $c->uncompress('uncompressed.dcm'); - * - * After (v2-native): DICOM\Compress::decompress(), with File::transferSyntaxUID(). + * Uses DICOM\Compress::decompress(), with File::transferSyntaxUID(). */ declare(strict_types=1); diff --git a/examples/write_tags.php b/examples/write_tags.php index 4c55d34..34a1a38 100755 --- a/examples/write_tags.php +++ b/examples/write_tags.php @@ -2,16 +2,11 @@ string pairs, the only - * option v1 offered: - * $d = new dicom_tag; $d->file = 'dean.dcm'; - * $d->write_tags(['0010,0010' => 'VAUGHAN^DEAN', '0008,0080' => 'DEANLAND, AR']); - * - * After (v2-native): typed setters that take validated value objects and persist in - * place. Prefer these; the raw Dataset::put() remains for tags without a setter. - * This demo copies the source first so the bundled fixture is never mutated. + * Typed setters that take validated value objects and persist in place. Prefer + * these; the raw Dataset::put() remains for tags without a setter. This demo + * copies the source first so the bundled fixture is never mutated. */ declare(strict_types=1); diff --git a/src/Compat/ShimContract.php b/src/Compat/ShimContract.php deleted file mode 100644 index 62ef5af..0000000 --- a/src/Compat/ShimContract.php +++ /dev/null @@ -1,319 +0,0 @@ -getMessage(), E_USER_WARNING); - - return self::softenedValue($onSoftenedFailure, $exception); - } catch (\InvalidArgumentException $exception) { - @trigger_error($exception->getMessage(), E_USER_DEPRECATED); - - return self::softenedValue($onSoftenedFailure, $exception); - } - } - - /** Resolve a softened-failure value: invoke a Closure deriver, else return verbatim. */ - private static function softenedValue(mixed $onSoftenedFailure, \Throwable $exception): mixed - { - return $onSoftenedFailure instanceof \Closure - ? ($onSoftenedFailure)($exception) - : $onSoftenedFailure; - } - - /** - * Execute()'s engine. Runs the command through a shell with v1's `2>&1` - * appended, captures stdout, and lets stderr inherit the parent process. That - * reproduces v1 exactly, including the compound-command quirk: when the command - * already redirects fd1 (e.g. `cmd >&2`), the appended `2>&1` mis-binds and the - * stderr text leaks to the parent rather than being captured. The returned - * string is verbatim -- no trim. A genuine inability to start the shell fails - * loud rather than masquerading as empty output. - */ - public static function shellCapture(string $command): string - { - $command .= ' 2>&1'; - $descriptors = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => fopen('php://stderr', 'w'), - ]; - $process = proc_open($command, $descriptors, $pipes); - if (!is_resource($process)) { - throw new \RuntimeException("Execute() could not start a shell for: {$command}"); - } - fclose($pipes[0]); - $stdout = stream_get_contents($pipes[1]); - fclose($pipes[1]); - proc_close($process); - - return $stdout === false ? '' : $stdout; - } - - /** - * is_dcm()'s engine. Issues v1's literal `dcmdump -M +L +Qn ` and maps - * the result to 1/0 -- preserving v1's use of dcmdump (not dcmftest) as the - * detection oracle. dcmdump exits 0 for a DICOM file and non-zero otherwise, so - * 1 means DICOM and 0 means not. The call is wall-clock guarded: if it does not - * finish in time the process is killed, an E_USER_WARNING is emitted, and 0 is - * returned so the caller is never blocked. Never throws. - */ - public static function dcmdumpDetect(string $binary, string $file): int - { - $descriptors = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - $process = proc_open([$binary, '-M', '+L', '+Qn', $file], $descriptors, $pipes); - if (!is_resource($process)) { - @trigger_error("is_dcm(): could not start dcmdump for '{$file}'.", E_USER_WARNING); - - return 0; - } - fclose($pipes[0]); - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - - $deadline = microtime(true) + self::$dcmdumpTimeoutSeconds; - $timedOut = false; - while (true) { - $status = proc_get_status($process); - // Drain both pipes each tick so a tool that fills one cannot deadlock. - stream_get_contents($pipes[1]); - stream_get_contents($pipes[2]); - if (!$status['running']) { - break; - } - if (microtime(true) >= $deadline) { - $timedOut = true; - proc_terminate($process, 9); - break; - } - usleep(20000); - } - fclose($pipes[1]); - fclose($pipes[2]); - $exitCode = proc_close($process); - - if ($timedOut) { - @trigger_error( - "is_dcm(): dcmdump did not return within " . self::$dcmdumpTimeoutSeconds - . "s for '{$file}'; returning 0.", - E_USER_WARNING, - ); - - return 0; - } - - return $exitCode === 0 ? 1 : 0; - } - - /** - * Read every top-level tag from $file with dcmdump in name-rendering mode (no - * -Un, so dcmdump maps well-known UIDs to dictionary names) and return a - * "gggg,eeee" => value map. This is the shim-local counterpart to Dataset's - * numeric read: the base Dataset always reads with -Un and never produces a - * name-mapped value, so the name rendering -- and the parse that strips its - * marker -- live here, not in core. A read failure (e.g. a missing file: - * dcmdump exits non-zero with no output) yields an empty map, matching v1's - * loose load_tags. A missing dcmdump binary throws a DCMTK exception for the - * caller's soften layer. - * - * @return array - */ - public static function readNameRenderedTags(Toolkit $toolkit, string $file): array - { - $result = $toolkit->run('dcmdump', [ - '-q', - '-M', - '+L', - '+R', - (string) Dataset::DEFAULT_MAX_READ_LENGTH_KB, - $file, - ]); - - return self::parseNameRenderedDump($result->stdout); - } - - /** - * Parse dcmdump name-mode output into a "gggg,eeee" => value map. Mirrors the - * shape Dataset parses, with one addition for this mode: dcmdump prints a - * name-mapped UID as "=Name" (e.g. "=JPEGBaseline"), so a leading "=" is - * stripped to yield the bare name. A bracketed value "[...]" (any unmapped - * value, including a UID dcmdump could not name) has its brackets stripped, - * exactly as the numeric read does. Values skipped by -M for exceeding +R are - * omitted from the map. - * - * @return array - */ - public static function parseNameRenderedDump(string $dump): array - { - $pattern = '/^\((?[0-9a-fA-F]{4}),(?[0-9a-fA-F]{4})\) ' - . '(?..) (?.*?) +# +(?\d+), +\d+ /m'; - preg_match_all($pattern, $dump, $matches, PREG_SET_ORDER); - - $tags = []; - foreach ($matches as $match) { - $key = strtolower($match['group']) . ',' . strtolower($match['element']); - $value = $match['value']; - $length = (int) $match['length']; - if ($value === '(not loaded)') { - continue; - } - if ($length === 0) { - $tags[$key] = ''; - } elseif ($value !== '' && $value[0] === '[' && str_ends_with($value, ']')) { - $tags[$key] = substr($value, 1, -1); - } elseif ($value !== '' && $value[0] === '=') { - $tags[$key] = substr($value, 1); - } else { - $tags[$key] = $value; - } - } - - return $tags; - } - - /** - * Run a primary render, and on a substrate-layer failure fall back to a second - * render -- reproducing v1's dcm_to_jpg/dcm_to_tn behavior of trying VOI window - * 1 and dropping to min-max windowing for images with no stored window. Emits - * the method deprecation once up front; when the fallback is actually engaged - * it emits a second E_USER_DEPRECATED ($fallbackNotice) flagging that this - * silent fallback is going away in v3. If the fallback also fails, the failure - * is softened to an E_USER_WARNING (the caller still gets back its output path, - * exactly as v1 returns the path regardless of success). - * - * @param callable():void $primary - * @param callable():void $fallback - */ - public static function runWithVoiFallback( - string $notice, - string $fallbackNotice, - callable $primary, - callable $fallback, - ): void { - self::deprecate($notice); - try { - $primary(); - - return; - } catch (DICOMException | PACSException | ToolkitException $primaryFailure) { - @trigger_error($fallbackNotice, E_USER_DEPRECATED); - } - try { - $fallback(); - } catch (DICOMException | PACSException | ToolkitException $fallbackFailure) { - @trigger_error($fallbackFailure->getMessage(), E_USER_WARNING); - } - } - - /** - * Apply a v1 "gggg,eeee" => value tag map to an existing file via Dataset::put. - * A structurally malformed key (not two hex groups) is skipped with an - * E_USER_WARNING rather than aborting the whole conversion -- v1 silently - * ignored such keys, and the shim surfaces the skip instead of hiding it. A - * Dataset::put that the toolkit rejects throws a marker for the caller's soften. - * - * @param array $arr_info - */ - public static function applyTags(string $path, array $arr_info): void - { - if ($arr_info === []) { - return; - } - $dataset = new Dataset($path); - foreach ($arr_info as $key => $value) { - $parts = explode(',', (string) $key); - if (count($parts) !== 2 || !ctype_xdigit($parts[0]) || !ctype_xdigit($parts[1])) { - @trigger_error( - "ignored malformed tag key '{$key}' (expected \"GGGG,EEEE\").", - E_USER_WARNING, - ); - - continue; - } - $dataset->put(hexdec($parts[0]), hexdec($parts[1]), (string) $value); - } - } -} diff --git a/src/DICOM/Compress.php b/src/DICOM/Compress.php index fc88def..512721e 100644 --- a/src/DICOM/Compress.php +++ b/src/DICOM/Compress.php @@ -14,7 +14,7 @@ * the path it is given, never mutates the process working directory, and returns * the opened result so it can be inspected or tagged further. * - * Scope is JPEG, matching v1: compress wraps dcmcjpeg and decompress wraps + * Scope is JPEG: compress wraps dcmcjpeg and decompress wraps * dcmdjpeg. JPEG 2000 is not available in this DCMTK build and is a recorded gap. * Operational failures are DICOM\Exception\ExceptionInterface: IOException (the * source could not be read), ConversionException (the toolkit refused -- e.g. no @@ -34,8 +34,8 @@ public function __construct( /** * Compress the pixel data to a JPEG transfer syntax via dcmcjpeg, returning the - * opened result. The process defaults to lossless SV1, matching v1 (which is - * also dcmcjpeg's own default). + * opened result. The process defaults to lossless SV1, which is also + * dcmcjpeg's own default. * * @throws \InvalidArgumentException a lossy quality is outside [0, 100] * @throws \DICOM\Exception\IOException the source vanished or became unreadable diff --git a/src/DICOM/Convert.php b/src/DICOM/Convert.php index e6a2e45..fa404d1 100644 --- a/src/DICOM/Convert.php +++ b/src/DICOM/Convert.php @@ -40,8 +40,8 @@ public function __construct( /** * Render the image to a baseline JPEG at $outputPath via dcmj2pnm. * - * Windowing defaults to the first stored VOI window (matching v1's - * `dcm_to_jpg`); scaling defaults to none. Pass a Windowing or Scale to choose + * Windowing defaults to the first stored VOI window; scaling defaults to + * none. Pass a Windowing or Scale to choose * another mode. quality is the JPEG quality, 0..100. * * @throws \InvalidArgumentException quality is outside [0, 100] @@ -80,7 +80,7 @@ public function toJPEG( } /** - * Render a scaled-down JPEG thumbnail (v1's `dcm_to_tn`). A thin convenience + * Render a scaled-down JPEG thumbnail. A thin convenience * over toJPEG: the width is scaled to $widthPixels (aspect preserved) at a * lower default quality. Windowing defaults to the first stored VOI window. * @@ -222,7 +222,7 @@ private static function removeTempDirectory(string $directory): void * the File tag accessors. * * A single image with the default classic Secondary Capture SOP class yields a - * single-frame object (v1's `jpg_to_dcm`). Multiple images yield one multiframe + * single-frame object. Multiple images yield one multiframe * object, which requires SOPClass::newSC(); the classic class cannot hold more * than one frame, so that combination is rejected before img2dcm is invoked. * The images must share dimensions for a multiframe result; img2dcm fails loud @@ -284,8 +284,7 @@ public static function fromJpeg( /** * Create a DICOM Encapsulated PDF file from a PDF via pdf2dcm, returning the - * opened result so attributes can be stamped on it with the File tag accessors - * (this is v1's pdf_to_dcm territory). + * opened result so attributes can be stamped on it with the File tag accessors. * * pdf2dcm wraps the whole document as a single Encapsulated PDF Storage * instance -- there are no frames and only the one SOP class, so neither the diff --git a/tests/CompatDicomConvertCreateTest.php b/tests/CompatDicomConvertCreateTest.php deleted file mode 100644 index 4ae9947..0000000 --- a/tests/CompatDicomConvertCreateTest.php +++ /dev/null @@ -1,195 +0,0 @@ - */ - private array $tempPaths = []; - - protected function tearDown(): void - { - foreach ($this->tempPaths as $path) { - if (is_file($path)) { - unlink($path); - } - } - $this->tempPaths = []; - } - - private function track(string $path): string - { - $this->tempPaths[] = $path; - - return $path; - } - - /** Render a baseline JPEG from the windowed example image. */ - private function sourceJpeg(): string - { - $jpg = $this->track(tempnam(sys_get_temp_dir(), 'cv_src_') . '.jpg'); - (new Convert(File::open(__DIR__ . '/../examples/dean.dcm')))->toJPEG($jpg, Windowing::useWindow(1)); - - return $jpg; - } - - /** A copy of the example PDF in a temp location, so its .dcm output stays in tmp. */ - private function samplePdf(): string - { - $pdf = $this->track(tempnam(sys_get_temp_dir(), 'cv_pdf_') . '.pdf'); - copy(__DIR__ . '/../examples/pdf.pdf', $pdf); - - return $pdf; - } - - private function tagValue(string $path, int $group, int $element): string - { - return (string) (new Dataset($path))->get($group, $element); - } - - // ---- jpg_to_dcm ------------------------------------------------------------- - - public function testJpgToDcmCreatesDicomAtJpgPathWithAppliedTags(): void - { - $jpg = $this->sourceJpeg(); - $convert = new \dicom_convert(); - $convert->jpg_file = $jpg; - - $out = $this->capture(static fn (): mixed => $convert->jpg_to_dcm([ - '0010,0010' => 'CREATE^TEST', - '0010,0020' => 'ID-JPG-1', - ])); - $this->track((string) $out); - - $this->assertSame($jpg . '.dcm', $out); - $this->assertSame('CREATE^TEST', $this->tagValue($out, 0x0010, 0x0010)); - $this->assertSame('ID-JPG-1', $this->tagValue($out, 0x0010, 0x0020)); - // No fallback/template noise on the normal path: only the method deprecation. - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - } - - public function testJpgToDcmMintsConsistentSopInstanceUidAvoidingV1Bug(): void - { - $jpg = $this->sourceJpeg(); - $convert = new \dicom_convert(); - $convert->jpg_file = $jpg; - - $out = $this->capture(static fn (): mixed => $convert->jpg_to_dcm([])); - $this->track((string) $out); - - // v1's xml2dcm path left the MetaHeader and Dataset SOPInstanceUID different; - // img2dcm mints one consistent UID, so the two match and are non-empty. - $metaUid = $this->tagValue($out, 0x0002, 0x0003); - $dataUid = $this->tagValue($out, 0x0008, 0x0018); - $this->assertNotSame('', $dataUid); - $this->assertSame($metaUid, $dataUid); - } - - public function testJpgToDcmDeprecatesWhenTemplateIsSet(): void - { - $jpg = $this->sourceJpeg(); - $convert = new \dicom_convert(); - $convert->jpg_file = $jpg; - $convert->template = 'some_template.xml'; - - $out = $this->capture(static fn (): mixed => $convert->jpg_to_dcm([])); - $this->track((string) $out); - - $deprecations = $this->noticesOf(E_USER_DEPRECATED); - $this->assertCount(2, $deprecations); - $templateNotice = implode("\n", array_map(static fn (array $n): string => $n[1], $deprecations)); - $this->assertStringContainsString('template', $templateNotice); - $this->assertStringContainsString('v3', $templateNotice); - } - - public function testJpgToDcmSkipsMalformedTagKeyWithWarningButStillCreatesFile(): void - { - $jpg = $this->sourceJpeg(); - $convert = new \dicom_convert(); - $convert->jpg_file = $jpg; - - $out = $this->capture(static fn (): mixed => $convert->jpg_to_dcm([ - 'not-a-key' => 'X', - '0010,0010' => 'STILL^APPLIED', - ])); - $this->track((string) $out); - - $this->assertFileExists($out); - $this->assertSame('STILL^APPLIED', $this->tagValue($out, 0x0010, 0x0010)); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - } - - public function testJpgToDcmReturnsPathAndWarnsWhenSourceMissing(): void - { - $convert = new \dicom_convert(); - $convert->jpg_file = '/no/such/source.jpg'; - - $out = $this->capture(static fn (): mixed => $convert->jpg_to_dcm([])); - - $this->assertSame('/no/such/source.jpg.dcm', $out); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - $this->assertFileDoesNotExist('/no/such/source.jpg.dcm'); - } - - // ---- pdf_to_dcm / pdf_to_dcmcr ---------------------------------------------- - - public function testPdfToDcmCreatesEncapsulatedPdfWithAppliedTags(): void - { - $pdf = $this->samplePdf(); - $convert = new \dicom_convert($pdf); - - $out = $this->capture(static fn (): mixed => $convert->pdf_to_dcm([ - '0010,0010' => 'PDF^TEST', - ])); - $this->track((string) $out); - - $this->assertSame($pdf . '.dcm', $out); - $this->assertSame(self::TS_ENCAPSULATED_PDF, $this->tagValue($out, 0x0008, 0x0016)); - $this->assertSame('PDF^TEST', $this->tagValue($out, 0x0010, 0x0010)); - } - - public function testPdfToDcmcrIsAliasEmittingItsOwnDeprecation(): void - { - $pdf = $this->samplePdf(); - $convert = new \dicom_convert($pdf); - - $out = $this->capture(static fn (): mixed => $convert->pdf_to_dcmcr([])); - $this->track((string) $out); - - $this->assertSame($pdf . '.dcm', $out); - $this->assertSame(self::TS_ENCAPSULATED_PDF, $this->tagValue($out, 0x0008, 0x0016)); - // The alias deprecation plus the underlying pdf_to_dcm deprecation. - $this->assertCount(2, $this->noticesOf(E_USER_DEPRECATED)); - } - - // ---- properties ------------------------------------------------------------- - - public function testCreationPropertyDefaults(): void - { - $convert = new \dicom_convert('x.pdf'); - - $this->assertSame('', $convert->template); - $this->assertSame('', $convert->temp_dir); - } -} diff --git a/tests/CompatDicomConvertTest.php b/tests/CompatDicomConvertTest.php deleted file mode 100644 index f431904..0000000 --- a/tests/CompatDicomConvertTest.php +++ /dev/null @@ -1,216 +0,0 @@ - - * min-max fallback with its v3-removal deprecation, and the lossless-SV1 compress - * default -- against real renders and transfer-syntax checks. Authored from the v1 - * footprint and observed behavior, never the legacy source. - */ -final class CompatDicomConvertTest extends TestCase -{ - use CapturesUserNotices; - - private const TS_JPEG_LOSSLESS_SV1 = '1.2.840.10008.1.2.4.70'; - private const TS_EXPLICIT_VR_LE = '1.2.840.10008.1.2.1'; - - /** @var list */ - private array $tempPaths = []; - - protected function tearDown(): void - { - foreach ($this->tempPaths as $path) { - if (is_file($path)) { - unlink($path); - } - } - $this->tempPaths = []; - } - - private function track(string $path): string - { - $this->tempPaths[] = $path; - - return $path; - } - - /** A renderable windowed image: examples/dean.dcm defines a VOI window. */ - private function windowedImageCopy(): string - { - $path = $this->track(tempnam(sys_get_temp_dir(), 'cv_win_') . '.dcm'); - copy(__DIR__ . '/../examples/dean.dcm', $path); - - return $path; - } - - /** A renderable image with no VOI window, forcing the min-max fallback. */ - private function noWindowImageCopy(): string - { - $path = $this->track(tempnam(sys_get_temp_dir(), 'cv_now_') . '.dcm'); - copy(__DIR__ . '/fixtures/pixels_nowindow.dcm', $path); - - return $path; - } - - private function compressedImageCopy(): string - { - $path = $this->track(tempnam(sys_get_temp_dir(), 'cv_cmp_') . '.dcm'); - copy(__DIR__ . '/fixtures/jpeg_baseline.dcm', $path); - - return $path; - } - - private function isJpeg(string $path): bool - { - return is_file($path) && filesize($path) > 0 && str_starts_with((string) file_get_contents($path, length: 2), "\xFF\xD8"); - } - - private function transferSyntax(string $path): string - { - return (string) (new Dataset($path))->get(0x0002, 0x0010); - } - - // ---- dcm_to_jpg ------------------------------------------------------------- - - public function testDcmToJpgRendersWindowedImageWithoutFallback(): void - { - $source = $this->windowedImageCopy(); - $tag = new \dicom_convert($source); - - $out = $this->capture(static fn (): mixed => $tag->dcm_to_jpg()); - $this->track($out); - - $this->assertSame($source . '.jpg', $out); - $this->assertSame($out, $tag->jpg_file); - $this->assertTrue($this->isJpeg($out)); - // Windowed image: window 1 succeeds, so only the method deprecation fires. - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - } - - public function testDcmToJpgFallsBackToMinMaxAndFlagsV3Removal(): void - { - $source = $this->noWindowImageCopy(); - $tag = new \dicom_convert($source); - - $out = $this->capture(static fn (): mixed => $tag->dcm_to_jpg()); - $this->track($out); - - $this->assertTrue($this->isJpeg($out)); - $deprecations = $this->noticesOf(E_USER_DEPRECATED); - $this->assertCount(2, $deprecations); - $this->assertStringContainsString('v3', $deprecations[1][1]); - $this->assertStringContainsString('min-max', $deprecations[1][1]); - } - - public function testDcmToJpgSetsJpgFileToOutputIgnoringAnyPresetValue(): void - { - $source = $this->windowedImageCopy(); - $tag = new \dicom_convert($source); - $tag->jpg_file = '/tmp/ignored_preset.jpg'; - - $out = $this->capture(static fn (): mixed => $tag->dcm_to_jpg()); - $this->track($out); - - $this->assertSame($source . '.jpg', $out); - $this->assertSame($source . '.jpg', $tag->jpg_file); - } - - // ---- dcm_to_tn -------------------------------------------------------------- - - public function testDcmToTnRendersThumbnailToUnderscoreTnPath(): void - { - $source = $this->windowedImageCopy(); - $tag = new \dicom_convert($source); - - $out = $this->capture(static fn (): mixed => $tag->dcm_to_tn()); - $this->track($out); - - $this->assertSame($source . '_tn.jpg', $out); - $this->assertSame($out, $tag->tn_file); - $this->assertTrue($this->isJpeg($out)); - } - - public function testDcmToTnFallsBackToMinMaxAndFlagsV3Removal(): void - { - $source = $this->noWindowImageCopy(); - $tag = new \dicom_convert($source); - - $out = $this->capture(static fn (): mixed => $tag->dcm_to_tn()); - $this->track($out); - - $this->assertTrue($this->isJpeg($out)); - $deprecations = $this->noticesOf(E_USER_DEPRECATED); - $this->assertCount(2, $deprecations); - $this->assertStringContainsString('v3', $deprecations[1][1]); - } - - // ---- compress / uncompress -------------------------------------------------- - - public function testCompressOverwritesInPlaceWithLosslessSV1(): void - { - $source = $this->noWindowImageCopy(); - $tag = new \dicom_convert($source); - - $out = $this->capture(static fn (): mixed => $tag->compress()); - - $this->assertSame($source, $out); - $this->assertSame(self::TS_JPEG_LOSSLESS_SV1, $this->transferSyntax($source)); - } - - public function testCompressWritesToNewFileLeavingSourceUntouched(): void - { - $source = $this->noWindowImageCopy(); - $target = $this->track(tempnam(sys_get_temp_dir(), 'cv_out_') . '.dcm'); - $tag = new \dicom_convert($source); - - $out = $this->capture(static fn (): mixed => $tag->compress($target)); - - $this->assertSame($target, $out); - $this->assertSame(self::TS_JPEG_LOSSLESS_SV1, $this->transferSyntax($target)); - $this->assertSame(self::TS_EXPLICIT_VR_LE, $this->transferSyntax($source)); - } - - public function testUncompressProducesUncompressedTransferSyntax(): void - { - $source = $this->compressedImageCopy(); - $target = $this->track(tempnam(sys_get_temp_dir(), 'cv_unc_') . '.dcm'); - $tag = new \dicom_convert($source); - - $out = $this->capture(static fn (): mixed => $tag->uncompress($target)); - - $this->assertSame($target, $out); - $this->assertSame(self::TS_EXPLICIT_VR_LE, $this->transferSyntax($target)); - } - - public function testCompressReturnsOutputPathAndWarnsOnFailure(): void - { - $tag = new \dicom_convert('/no/such/dir/missing.dcm'); - - $out = $this->capture(static fn (): mixed => $tag->compress('/tmp/never_written.dcm')); - - $this->assertSame('/tmp/never_written.dcm', $out); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - $this->assertFileDoesNotExist('/tmp/never_written.dcm'); - } - - // ---- properties ------------------------------------------------------------- - - public function testPropertyDefaults(): void - { - $tag = new \dicom_convert('some/path.dcm'); - - $this->assertSame('some/path.dcm', $tag->file); - $this->assertSame(100, $tag->jpg_quality); - $this->assertSame(125, $tag->tn_size); - $this->assertSame('', $tag->jpg_file); - $this->assertSame('', $tag->tn_file); - } -} diff --git a/tests/CompatDicomConvertVideoTest.php b/tests/CompatDicomConvertVideoTest.php deleted file mode 100644 index 6dbaa35..0000000 --- a/tests/CompatDicomConvertVideoTest.php +++ /dev/null @@ -1,148 +0,0 @@ - */ - private array $tempDirs = []; - - protected function tearDown(): void - { - foreach ($this->tempDirs as $directory) { - foreach (glob($directory . '/*') ?: [] as $file) { - @unlink($file); - } - @rmdir($directory); - } - $this->tempDirs = []; - } - - private function tempDir(bool $create = true): string - { - $directory = sys_get_temp_dir() . '/mfvShim' . bin2hex(random_bytes(6)); - $this->tempDirs[] = $directory; - if ($create) { - mkdir($directory, 0775, true); - } - - return $directory; - } - - private function durationSeconds(string $path): float - { - $output = []; - exec( - 'ffprobe -v error -show_entries format=duration -of csv=p=0 ' . escapeshellarg($path), - $output, - ); - - return (float) trim(implode('', $output)); - } - - public function testProducesMp4AtTempDirPath(): void - { - $directory = $this->tempDir(); - $convert = new \dicom_convert(self::MULTIFRAME); - - $out = $this->capture(fn (): string => $convert->multiframe_to_video('mp4', 10, $directory)); - - $this->assertSame($directory . '/multiframe.dcm.mp4', $out); - $this->assertFileExists($out); - $this->assertGreaterThan(0, filesize($out)); - $this->assertStringContainsString('ftyp', (string) file_get_contents($out, length: 64)); - } - - public function testEmitsDeprecationNotice(): void - { - $directory = $this->tempDir(); - $convert = new \dicom_convert(self::MULTIFRAME); - - $this->capture(fn (): string => $convert->multiframe_to_video('mp4', 10, $directory)); - - $deprecations = $this->noticesOf(E_USER_DEPRECATED); - $this->assertCount(1, $deprecations); - $this->assertStringContainsString('multiframe_to_video()', $deprecations[0][1]); - $this->assertStringContainsString('toVideo()', $deprecations[0][1]); - } - - public function testHonorsFramerateUnlikeV1(): void - { - $convert = new \dicom_convert(self::MULTIFRAME); - $fastDir = $this->tempDir(); - $slowDir = $this->tempDir(); - - $fast = $this->capture(fn (): string => $convert->multiframe_to_video('mp4', 10, $fastDir)); - $slow = $this->capture(fn (): string => $convert->multiframe_to_video('mp4', 2, $slowDir)); - - $this->assertGreaterThan( - $this->durationSeconds($fast), - $this->durationSeconds($slow), - 'a lower framerate should yield a longer video; v1 ignored $framerate', - ); - } - - public function testCreatesMissingTempDir(): void - { - $base = $this->tempDir(create: false); - $nested = $base . '/nested'; - $convert = new \dicom_convert(self::MULTIFRAME); - - $out = $this->capture(fn (): string => $convert->multiframe_to_video('mp4', 10, $nested)); - - $this->assertDirectoryExists($nested); - $this->assertFileExists($out); - // Track the nested dir too so tearDown cleans it. - $this->tempDirs[] = $nested; - } - - public function testRejectsNonMp4Format(): void - { - $convert = new \dicom_convert(self::MULTIFRAME); - $this->expectException(\InvalidArgumentException::class); - $convert->multiframe_to_video('avi'); - } - - public function testSoftensMarkerFailureToOutputPath(): void - { - $directory = $this->tempDir(); - $missing = $directory . '/missing.dcm'; - $convert = new \dicom_convert($missing); - - $out = $this->capture(fn (): string => $convert->multiframe_to_video('mp4', 10, $directory)); - - // The source open fails with an IOException (a DICOM marker); the contract - // softens it to a warning and returns the v1-shaped output path. - $this->assertSame($directory . '/missing.dcm.mp4', $out); - $this->assertFileDoesNotExist($out); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - } - - public function testDoesNotChangeWorkingDirectory(): void - { - $directory = $this->tempDir(); - $convert = new \dicom_convert(self::MULTIFRAME); - $cwd = getcwd(); - - $this->capture(fn (): string => $convert->multiframe_to_video('mp4', 10, $directory)); - - $this->assertSame($cwd, getcwd()); - } -} diff --git a/tests/CompatDicomNetTest.php b/tests/CompatDicomNetTest.php deleted file mode 100644 index a1ab381..0000000 --- a/tests/CompatDicomNetTest.php +++ /dev/null @@ -1,326 +0,0 @@ - */ - private array $tempDirs = []; - - /** @var list */ - private array $startedHandles = []; - - private const FIXTURES = __DIR__ . '/fixtures'; - - protected function setUp(): void - { - $this->port = $this->freePort(); - $this->recv = sys_get_temp_dir() . '/netshim' . bin2hex(random_bytes(6)); - mkdir($this->recv, 0775, true); - $this->startStoreScp($this->port, $this->recv, 'ECHOSCP'); - } - - protected function tearDown(): void - { - foreach ($this->startedHandles as $handle) { - $handle->stop(); - } - $this->startedHandles = []; - $this->stopStoreScpPeers(); - foreach (array_merge([$this->recv], $this->tempDirs) as $directory) { - foreach (glob($directory . '/*') ?: [] as $file) { - @unlink($file); - } - @rmdir($directory); - } - $this->tempDirs = []; - } - - private function countReceived(): int - { - return count(glob($this->recv . '/*') ?: []); - } - - private function tempDir(): string - { - $dir = sys_get_temp_dir() . '/netshim' . bin2hex(random_bytes(6)); - mkdir($dir, 0775, true); - $this->tempDirs[] = $dir; - - return $dir; - } - - private function settleReceived(int $want, float $seconds = 4.0): void - { - $deadline = microtime(true) + $seconds; - while (microtime(true) < $deadline && $this->countReceived() < $want) { - usleep(100000); - } - } - - public function testEchoscuReturnsZeroOnSuccess(): void - { - $result = $this->capture(fn (): mixed => (new \dicom_net()) - ->echoscu('127.0.0.1', $this->port, 'ECHOSCU', 'ECHOSCP')); - - $this->assertSame(0, $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - } - - public function testEchoscuReturnsErrorStringWhenPeerUnreachable(): void - { - $dead = $this->freePort(); // nothing listening here - $result = $this->capture(fn (): mixed => (new \dicom_net()) - ->echoscu('127.0.0.1', $dead, 'ECHOSCU', 'ECHOSCP')); - - $this->assertIsString($result); - $this->assertNotSame('', $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - } - - public function testEchoscuSoftensRejectedAeTitleToErrorStringWithDeprecation(): void - { - $tooLong = str_repeat('A', 17); // v2 rejects AE titles over 16 chars - $result = $this->capture(fn (): mixed => (new \dicom_net()) - ->echoscu('127.0.0.1', $this->port, $tooLong, 'ECHOSCP')); - - // v1 never threw on a bad AE; the shim returns the rejection as an error - // string and surfaces it as a deprecation, not a runtime warning. - $this->assertIsString($result); - $this->assertStringContainsString('16 characters', $result); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - $this->assertCount(2, $this->noticesOf(E_USER_DEPRECATED)); - } - - public function testSendDcmSingleFileReturnsZeroAndStoresOne(): void - { - $net = new \dicom_net(); - $net->file = self::FIXTURES . '/explicit_vr_le.dcm'; - - $result = $this->capture(fn (): mixed => $net - ->send_dcm('127.0.0.1', $this->port, 'SENDSCU', 'ECHOSCP')); - - $this->assertSame(0, $result); - $this->settleReceived(1); - $this->assertSame(1, $this->countReceived()); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - } - - public function testSendDcmBatchSendsEveryFileInTheDirectory(): void - { - $send = sys_get_temp_dir() . '/netsend' . bin2hex(random_bytes(6)); - mkdir($send, 0775, true); - $this->tempDirs[] = $send; - // three DISTINCT objects (distinct SOP Instance UIDs) so all three store - foreach (['implicit_vr_le.dcm', 'explicit_vr_le.dcm', 'jpeg_baseline.dcm'] as $i => $name) { - copy(self::FIXTURES . '/' . $name, sprintf('%s/%02d.dcm', $send, $i)); - } - - $net = new \dicom_net(); - $net->file = $send . '/00.dcm'; - $result = $this->capture(fn (): mixed => $net - ->send_dcm('127.0.0.1', $this->port, 'SENDSCU', 'ECHOSCP', 1)); - - $this->assertSame(0, $result); - $this->settleReceived(3); - $this->assertSame(3, $this->countReceived()); - } - - public function testSendDcmReturnsErrorStringWhenPeerUnreachable(): void - { - $dead = $this->freePort(); - $net = new \dicom_net(); - $net->file = self::FIXTURES . '/explicit_vr_le.dcm'; - - $result = $this->capture(fn (): mixed => $net - ->send_dcm('127.0.0.1', $dead, 'SENDSCU', 'ECHOSCP')); - - $this->assertIsString($result); - $this->assertNotSame('', $result); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - } - - public function testSendDcmReturnsErrorStringWhenFileMissing(): void - { - $net = new \dicom_net(); - $net->file = self::FIXTURES . '/does_not_exist.dcm'; - - $result = $this->capture(fn (): mixed => $net - ->send_dcm('127.0.0.1', $this->port, 'SENDSCU', 'ECHOSCP')); - - $this->assertIsString($result); - $this->assertSame(0, $this->countReceived()); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - } - - public function testEchoscuConfiguredTimeoutReachesAssociation(): void - { - // An out-of-range timeout proves the property is passed through to - // Association (which rejects < 1); the rejection softens per the contract. - $net = new \dicom_net(); - $net->echo_acse_timeout = 0; - - $result = $this->capture(fn (): mixed => $net - ->echoscu('127.0.0.1', $this->port, 'ECHOSCU', 'ECHOSCP')); - - $this->assertIsString($result); - $this->assertStringContainsString('ACSE timeout', $result); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - $this->assertCount(2, $this->noticesOf(E_USER_DEPRECATED)); - } - - public function testTransferSyntaxIsInertButDeprecated(): void - { - $net = new \dicom_net(); - $net->file = self::FIXTURES . '/implicit_vr_le.dcm'; - $net->transfer_syntax = '1.2.840.10008.1.2.1'; // ignored, exactly as in v1 - - $result = $this->capture(fn (): mixed => $net - ->send_dcm('127.0.0.1', $this->port, 'SENDSCU', 'ECHOSCP')); - - $this->assertSame(0, $result); - $this->assertSame(1, $this->countReceived()); - // send_dcm's own deprecation plus the transfer_syntax-ignored one. - $messages = array_map(fn (array $n): string => $n[1], $this->noticesOf(E_USER_DEPRECATED)); - $this->assertCount(2, $messages); - $this->assertNotEmpty( - array_filter($messages, static fn (string $m): bool => str_contains($m, 'transfer_syntax')), - ); - } - - private function sendObject(int $port, string $callingAE, string $calledAE, string $fixture): void - { - (new SCU(new Peer('127.0.0.1', $port, $calledAE), new Association($callingAE))) - ->send(File::open($fixture)); - } - - /** Write an executable handler that logs each placeholder it receives, one per line. */ - private function writeHandler(string $log): string - { - $path = dirname($log) . '/handler.sh'; - file_put_contents($path, "#!/bin/sh\nprintf '%s\\n' \"\$@\" >> " . escapeshellarg($log) . "\n"); - chmod($path, 0755); - - return $path; - } - - public function testStoreServerRunsHandlerWithPlaceholders(): void - { - $recv = $this->tempDir(); - $log = $this->tempDir() . '/handler.log'; - $handler = $this->writeHandler($log); - $port = $this->freePort(); - - $net = new \dicom_net(); - $net->blocking = false; // return the handle instead of blocking - $handle = $this->capture(fn (): mixed => $net->store_server($port, $recv, $handler)); - - $this->assertInstanceOf(SCPProcess::class, $handle); - $this->startedHandles[] = $handle; - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - - $this->sendObject($port, 'SENDERAE', 'RECVRAE', self::FIXTURES . '/implicit_vr_le.dcm'); - - $args = []; - $deadline = microtime(true) + 6.0; - while (microtime(true) < $deadline) { - $args = is_file($log) - ? array_values(array_filter(explode("\n", (string) file_get_contents($log)), 'strlen')) - : []; - if (count($args) >= 4) { - break; - } - usleep(100000); - } - - $this->assertCount(4, $args, 'handler did not run with the four placeholders'); - // v1 placeholder order #p #f #c #a: storage dir, stored file, called AE (us), calling AE (sender). - $this->assertSame(realpath($recv), realpath($args[0])); - $this->assertFileExists($args[0] . '/' . $args[1]); - $this->assertSame('RECVRAE', $args[2]); - $this->assertSame('SENDERAE', $args[3]); - } - - public function testStoreServerSoftensStartupFailure(): void - { - $port = $this->freePort(); - $occupier = (new \PACS\SCP($port, $this->tempDir()))->start(); - $this->startedHandles[] = $occupier; - - $net = new \dicom_net(); - $net->blocking = false; - $result = $this->capture(fn (): mixed => $net->store_server($port, $this->tempDir(), '')); - - $this->assertNull($result); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - } - - public function testStoreServerBlocksUntilStopped(): void - { - foreach (['pcntl_fork', 'pcntl_waitpid', 'posix_setsid', 'posix_kill'] as $fn) { - if (!function_exists($fn)) { - $this->markTestSkipped("requires {$fn}"); - } - } - - $port = $this->freePort(); - $recv = $this->tempDir(); - - $pid = pcntl_fork(); - $this->assertNotSame(-1, $pid, 'fork failed'); - if ($pid === 0) { - // Child: new session so the whole group (php child + storescp) can be reaped - // together. Default blocking=true means store_server never returns here. - posix_setsid(); - $net = new \dicom_net(); - @$net->store_server($port, $recv, ''); - exit(0); - } - - $up = false; - $deadline = microtime(true) + 5.0; - while (microtime(true) < $deadline) { - $client = @stream_socket_client("tcp://127.0.0.1:{$port}", $errno, $errstr, 0.2); - if ($client !== false) { - fclose($client); - $up = true; - break; - } - usleep(100000); - } - - $this->assertTrue($up, 'blocking store_server never started listening'); - // Still blocked (not returned) -> the child has not exited. - $this->assertSame(0, pcntl_waitpid($pid, $status, WNOHANG), 'store_server returned instead of blocking'); - - posix_kill(-$pid, SIGKILL); // kill the child's whole session (php child + storescp) - pcntl_waitpid($pid, $status); - } -} diff --git a/tests/CompatDicomTagTest.php b/tests/CompatDicomTagTest.php deleted file mode 100644 index 71fa4f7..0000000 --- a/tests/CompatDicomTagTest.php +++ /dev/null @@ -1,154 +0,0 @@ -writableCopy !== '' && is_file($this->writableCopy)) { - unlink($this->writableCopy); - } - $this->writableCopy = ''; - } - - private function fixture(string $name): string - { - return __DIR__ . '/fixtures/' . $name; - } - - private function writableFixtureCopy(string $name): string - { - $this->writableCopy = tempnam(sys_get_temp_dir(), 'compat_tag_') . '.dcm'; - copy($this->fixture($name), $this->writableCopy); - - return $this->writableCopy; - } - - // ---- load_tags -------------------------------------------------------------- - - public function testLoadTagsPopulatesNameRenderedTagsAndReturnsNull(): void - { - $tag = new \dicom_tag($this->fixture('jpeg_baseline.dcm')); - - $result = $this->capture(static fn (): mixed => $tag->load_tags()); - - $this->assertNull($result); - $this->assertSame('JPEGBaseline', $tag->tags['0002,0010']); - $this->assertSame('SecondaryCaptureImageStorage', $tag->tags['0008,0016']); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - } - - public function testLoadTagsRendersUnmappableUidNumerically(): void - { - $tag = new \dicom_tag($this->fixture('jpeg_baseline.dcm')); - $this->capture(static fn (): mixed => $tag->load_tags()); - - // SOPInstanceUID has no dictionary name; dcmdump prints it bracketed, so the - // parse strips the brackets and the value stays the raw UID. - $this->assertSame(self::UNMAPPABLE_SOP_INSTANCE_UID, $tag->tags['0008,0018']); - } - - public function testLoadTagsLeavesTagsEmptyAndReturnsNullForMissingFile(): void - { - $tag = new \dicom_tag('/no/such/file.dcm'); - - $result = $this->capture(static fn (): mixed => $tag->load_tags()); - - $this->assertNull($result); - $this->assertSame([], $tag->tags); - } - - // ---- get_tag ---------------------------------------------------------------- - - public function testGetTagReadsLoadedValueAndReturnsEmptyForMissingKey(): void - { - $tag = new \dicom_tag($this->fixture('explicit_vr_le.dcm')); - $this->capture(static fn (): mixed => $tag->load_tags()); - - $this->assertSame('LittleEndianExplicit', $this->capture(static fn (): string => $tag->get_tag('0002', '0010'))); - $this->assertSame('', $this->capture(static fn (): string => $tag->get_tag('9999', '9999'))); - } - - public function testGetTagReturnsEmptyBeforeLoadTagsWithoutReadingTheFile(): void - { - // v1's get_tag is a pure lookup: with no load_tags() call, $tags is empty - // and every lookup returns '' even though the file is a valid DICOM. - $tag = new \dicom_tag($this->fixture('explicit_vr_le.dcm')); - - $result = $this->capture(static fn (): string => $tag->get_tag('0002', '0010')); - - $this->assertSame('', $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - } - - // ---- write_tags ------------------------------------------------------------- - - public function testWriteTagsReturnsIntZeroOnSuccessAndPersists(): void - { - $path = $this->writableFixtureCopy('jpeg_baseline.dcm'); - $tag = new \dicom_tag($path); - - $result = $this->capture(static fn (): mixed => $tag->write_tags([ - '0010,0010' => 'PROBE^WRITE', - '0010,0020' => 'ID12345', - ])); - - $this->assertSame(0, $result); - - $reader = new \dicom_tag($path); - $this->capture(static fn (): mixed => $reader->load_tags()); - $this->assertSame('PROBE^WRITE', $reader->tags['0010,0010']); - $this->assertSame('ID12345', $reader->tags['0010,0020']); - } - - public function testWriteTagsReturnsErrorStringForMissingFile(): void - { - $tag = new \dicom_tag('/no/such/dir/nope.dcm'); - - $result = $this->capture(static fn (): mixed => $tag->write_tags(['0010,0010' => 'X'])); - - $this->assertIsString($result); - $this->assertNotSame('', $result); - $this->assertCount(1, $this->noticesOf(E_USER_WARNING)); - } - - public function testWriteTagsReturnsErrorStringForMalformedKey(): void - { - $path = $this->writableFixtureCopy('jpeg_baseline.dcm'); - $tag = new \dicom_tag($path); - - $result = $this->capture(static fn (): mixed => $tag->write_tags(['not-a-tag-key' => 'X'])); - - $this->assertIsString($result); - $this->assertStringContainsString('malformed', $result); - } - - // ---- properties ------------------------------------------------------------- - - public function testFilePropertyHoldsConstructorPathVerbatim(): void - { - $tag = new \dicom_tag('some/relative/path.dcm'); - - $this->assertSame('some/relative/path.dcm', $tag->file); - $this->assertSame([], $tag->tags); - } -} diff --git a/tests/CompatShimTest.php b/tests/CompatShimTest.php deleted file mode 100644 index dba5a6f..0000000 --- a/tests/CompatShimTest.php +++ /dev/null @@ -1,233 +0,0 @@ -capture( - static fn (): string => ShimContract::run('dep', static fn (): string => 'ok', 'fallback'), - ); - - $this->assertSame('ok', $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - } - - public function testRunSoftensLayerExceptionToFailureValueWithOneWarning(): void - { - $result = $this->capture(static fn (): string => ShimContract::run( - 'dep', - static function (): string { - throw new IOException('disk gone'); - }, - 'fallback', - )); - - $this->assertSame('fallback', $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $warnings = $this->noticesOf(E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertStringContainsString('disk gone', $warnings[0][1]); - } - - public function testRunLetsNonLayerExceptionPropagate(): void - { - $this->expectException(\RuntimeException::class); - - $this->capture(static fn (): mixed => ShimContract::run( - 'dep', - static function (): void { - throw new \RuntimeException('not a layer failure'); - }, - 'fallback', - )); - } - - public function testRunDerivesFailureValueFromClosureOnSoftenedFailure(): void - { - $result = $this->capture(static fn (): string => ShimContract::run( - 'dep', - static function (): string { - throw new IOException('boom'); - }, - static fn (\Throwable $e): string => 'ERR:' . $e->getMessage(), - )); - - $this->assertSame('ERR:boom', $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $warnings = $this->noticesOf(E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertStringContainsString('boom', $warnings[0][1]); - } - - public function testRunDoesNotInvokeFailureClosureOnSuccess(): void - { - $result = $this->capture(static fn (): string => ShimContract::run( - 'dep', - static fn (): string => 'ok', - static function (\Throwable $e): string { - throw new \RuntimeException('failure deriver must not run on success'); - }, - )); - - $this->assertSame('ok', $result); - } - - public function testRunReturnsCallableStringFailureValueVerbatim(): void - { - // 'strlen' is a valid callable string; the Closure check (not is_callable) - // must return it as-is rather than invoking it. - $result = $this->capture(static fn (): string => ShimContract::run( - 'dep', - static function (): string { - throw new IOException('disk gone'); - }, - 'strlen', - )); - - $this->assertSame('strlen', $result); - } - - public function testRunSoftensInvalidArgumentToDeprecationNotWarning(): void - { - $result = $this->capture(static fn (): string => ShimContract::run( - 'method is deprecated', - static function (): string { - throw new \InvalidArgumentException('AE title must be 1 to 16 characters'); - }, - 'fallback', - )); - - $this->assertSame('fallback', $result); - // No warning -- a strict-validation rejection is surfaced as a deprecation. - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - // Two deprecations: the method notice, plus the rejection message. - $deprecations = $this->noticesOf(E_USER_DEPRECATED); - $this->assertCount(2, $deprecations); - $this->assertStringContainsString('AE title must be 1 to 16 characters', $deprecations[1][1]); - } - - public function testRunInvalidArgumentWithClosureDeriverReturnsMessage(): void - { - $result = $this->capture(static fn (): string => ShimContract::run( - 'dep', - static function (): string { - throw new \InvalidArgumentException('port out of range'); - }, - static fn (\Throwable $e): string => $e->getMessage(), - )); - - $this->assertSame('port out of range', $result); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - } - - // ---- Execute ---------------------------------------------------------------- - - public function testExecuteReturnsStdoutVerbatimWithoutTrim(): void - { - $result = $this->capture(static fn (): string => \Execute('printf "a\nb"')); - - $this->assertSame("a\nb", $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - } - - public function testExecuteCapturesStdoutIncludingTrailingNewline(): void - { - $result = $this->capture(static fn (): string => \Execute('echo OUT')); - - $this->assertSame("OUT\n", $result); - } - - public function testExecuteEmptyCommandReturnsEmptyString(): void - { - $result = $this->capture(static fn (): string => \Execute('')); - - $this->assertSame('', $result); - } - - public function testExecuteDoesNotCaptureStderrFromCompoundCommand(): void - { - // v1 appends 2>&1, but for a compound command whose last stage redirects to - // fd2 the redirect mis-binds, so the explicit stderr write leaks to the - // parent and the return holds only the stdout stage. - $result = $this->capture(static fn (): string => \Execute('echo OUT; echo ERR >&2')); - - $this->assertSame("OUT\n", $result); - } - - // TODO: assert Execute() fails loud (throws) when proc_open cannot start a - // shell. There is no reliable way to force a proc_open start failure in this - // sandbox, so this is left as a tracked gap rather than a stub that would - // inflate the pass count. - - // ---- is_dcm ----------------------------------------------------------------- - - public function testIsDcmReturnsIntOneForDicomFileWithNoWarning(): void - { - // The literal v1 oracle `dcmdump -M +L +Qn ` exits 0 for a DICOM file, - // so is_dcm reports 1 -- as an int, not a bool. The normal path emits only - // the deprecation, no warning. - $result = $this->capture(fn (): int => \is_dcm($this->fixture('tags_sample.dcm'))); - - $this->assertSame(1, $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - } - - public function testIsDcmReturnsIntZeroForNonDicomFileWithNoWarning(): void - { - // dcmdump exits non-zero on a file that is not DICOM, so is_dcm reports 0. - $result = $this->capture(fn (): int => \is_dcm($this->fixture('not_dicom.bin'))); - - $this->assertSame(0, $result); - $this->assertCount(1, $this->noticesOf(E_USER_DEPRECATED)); - $this->assertSame([], $this->noticesOf(E_USER_WARNING)); - } - - public function testIsDcmNeverThrowsAndReturnsIntZeroForAMissingPath(): void - { - // A missing file is not DICOM: dcmdump exits non-zero, is_dcm returns 0, - // loosely (never throws), matching v1. - $result = $this->capture(fn (): int => \is_dcm('/no/such/path/at/all.dcm')); - - $this->assertIsInt($result); - $this->assertSame(0, $result); - } - - // TODO: exercise the dcmdumpDetect wall-clock guard (warn + 0 when the tool - // does not return). dcmdump returns promptly on the +Qn argv in practice, and - // the literal-v1 argv is hardcoded by design, so a slow stand-in cannot be - // injected without parameterizing the call to suit the test. Tracked, not - // stubbed. -} diff --git a/tests/ConvertTest.php b/tests/ConvertTest.php index ef64291..fea991b 100644 --- a/tests/ConvertTest.php +++ b/tests/ConvertTest.php @@ -108,8 +108,7 @@ public function testAbsentRequestedWindowFailsLoud(): void public function testDoesNotMutateWorkingDirectory(): void { - // v1's multiframe path chdir'd globally and never restored it. Guard against - // that: a conversion must leave the process cwd untouched. + // A conversion must leave the process working directory untouched. $before = getcwd(); (new Convert($this->image()))->toJPEG($this->outPath()); $this->assertSame($before, getcwd()); diff --git a/tools/README.md b/tools/README.md index e6c1135..cd00658 100644 --- a/tools/README.md +++ b/tools/README.md @@ -35,7 +35,7 @@ exactly one place. Tooling for observing what DCMTK actually does, used while building and verifying the wrappers. Run in the dev container (via `devenv/lxc/ct_exec.py`). -- `makeToolShims.sh` -- installs logging wrappers in `/usr/local/bin` (v1's default `TOOLKIT_DIR`) +- `makeToolShims.sh` -- installs logging wrappers in `/usr/local/bin` for every DCMTK binary (`dpkg -L dcmtk`) plus `ffmpeg`; each records `tool + argv` to a log, then exec's the real tool, so you can see which tool each operation actually calls. - `makeMultiframeFixture.py` -- synthesizes a small multi-frame DICOM (pydicom only) so