Reproduce the StockControls offline PDF dependencies
==================================================

This is complete source/build documentation for developers and reviewers, not a
WordPress feature or an installer. The plugin and PDF export never load or run
this documentation. Runtime exports use the bundled readable vendor tree and
local font. No .py, .phar or .sh files are shipped in the plugin.

The four included files are README.txt, composer.json, composer.lock and
NOTICE.txt. This README openly includes the complete maintained Python recipe
as a labelled source example below. It matches tools/pdf-dependencies/rebuild.py
in the development project; the executable development copy stays outside the
plugin distribution. The other three files are exact build inputs, including
the pinned dependency lock and license/adaptation notice. No private repository
or separate source download is needed to inspect or reproduce the transformation.

Developer requirements
----------------------
Use a separate development directory outside any public web root. You need
Python 3, PHP CLI 8.2 or newer on PATH as php, the PHP extensions required by the
locked packages (including mbstring and GD), and ZIP extraction support for
Composer (PHP zip or an installed unzip/7z program). PHP 8.2+ is a build-tool
requirement, not an increase to the plugin's PHP 7.4 runtime minimum.

An explicit rebuild needs network access to the official Composer, GitHub and
Packagist dependency-download endpoints. It downloads Composer 2.10.3 and
PHP-Scoper 0.18.19 and verifies their pinned SHA256 digests before execution.
Composer installs the exact composer.lock versions/revisions with development
dependencies, Composer plugins and Composer scripts disabled. Downloaded tools
and upstream archives remain in the chosen private work directory; they are not
part of the installed plugin. No global package installation is performed.

Build from an extracted plugin ZIP
---------------------------------
1. Create a new private recipe directory outside the plugin and any public web
   root, for example /path/to/private-pdf-recipe. Do not use an installed plugin
   folder as the work, output or recipe directory.
2. Copy composer.json, composer.lock and NOTICE.txt from this documentation
   directory into that private recipe directory, without changing their bytes.
3. Copy the complete Python source example below into a new file named
   rebuild.py in the same private recipe directory. Copy the source between the
   opening and closing backtick fences; omit the fences themselves. Preserve
   its final newline. Its SHA256 must match the value printed below.
4. Run the command below. Replace both output paths with new, distinct paths
   that do not exist. The recipe refuses existing --work or --output directories;
   it does not modify the installed/extracted vendor tree or publish anything.

python3 /path/to/private-pdf-recipe/rebuild.py \
  --work /path/to/new-private-pdf-work \
  --output /path/to/new-rebuilt-pdf-vendor

The recipe prefixes the readable sources with StockControlsPDFVendor, applies
the narrowly documented namespace/polyfill corrections, embeds only DejaVu Sans,
retains upstream license/attribution data, and omits unused development content.
Case-insensitive directory matching covers Test/tests and developer folders;
explicit exclusions cover the unused HTTP helper and Psalm development loader.
The exact six removed PHP files are asserted for this pinned dependency set.
The work directory's result.json records each final member's SHA256 and the
removed development files. NOTICE.txt documents versions, upstream references,
licenses, font hash and all adaptations.

Complete maintained rebuild source (Python 3)
---------------------------------------------
This source is included for transparent review and reproduction. It is not
loaded or executed by WordPress. Only the separate private developer copy made
in step 3 is runnable. The release builder checks this source example against
the maintained development script and verifies its declared digest.

Recipe source SHA256: c902ea0c4b9e855d55ab31e4a8ffe3e01467c4afafd3fd1d59e249cbc60ef7c7

```python
#!/usr/bin/env python3
"""Rebuild the readable, privately namespaced PDF vendor tree from exact upstream locks.
Only writes the new --work and --output directories. No installation/publication occurs.
Requires developer PHP8.2+, Python3 and network; runtime sites require none of these tools.
"""
from pathlib import Path
import argparse,hashlib,json,re,shutil,subprocess,urllib.request
HERE=Path(__file__).resolve().parent
TOOLS={
 'composer.phar':('https://getcomposer.org/download/2.10.3/composer.phar','7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6'),
 'php-scoper.phar':('https://github.com/humbug/php-scoper/releases/download/0.18.19/php-scoper.phar','170fb84bd3390defb30f99f7dc39c9a89d10c29973accc26f31c00abc5b25933'),
}
DEVELOPMENT_DIRS={'test','tests','testing','example','examples','demo','demos','bin','build','other'}
UNUSED_ENTRY_POINTS={'mpdf/mpdf/data/out.php','paragonie/random_compat/psalm-autoload.php'}
REMOVED_DEVELOPMENT_FILES={
 'mpdf/mpdf/data/out.php',
 'paragonie/random_compat/psalm-autoload.php',
 'paragonie/random_compat/other/build_phar.php',
 'psr/log/Psr/Log/Test/DummyTest.php',
 'psr/log/Psr/Log/Test/LoggerInterfaceTest.php',
 'psr/log/Psr/Log/Test/TestLogger.php',
}
def run(args,cwd=None):subprocess.run(args,cwd=cwd,check=True)
def snapshot(root):return {str(p.relative_to(root)):hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(root.rglob('*')) if p.is_file()}
def main():
 ap=argparse.ArgumentParser();ap.add_argument('--work',required=True,type=Path);ap.add_argument('--output',required=True,type=Path);args=ap.parse_args();work=args.work.resolve();output=args.output.resolve()
 assert not work.exists() and not output.exists(),'Use new directories; existing output is never overwritten'
 work.mkdir(parents=True);up=work/'upstream';up.mkdir()
 for name,(url,want) in TOOLS.items():
  data=urllib.request.urlopen(url,timeout=120).read();assert hashlib.sha256(data).hexdigest()==want,name;(work/name).write_bytes(data)
 for name in ['composer.json','composer.lock']:shutil.copyfile(HERE/name,up/name)
 run(['php',str(work/'composer.phar'),'install','--no-dev','--no-scripts','--no-plugins','--prefer-dist','--no-interaction'],up)
 stage=work/'scope-input';stage.mkdir();vendor=stage/'vendor'
 shutil.copytree(up/'vendor',vendor,ignore=shutil.ignore_patterns('ttfonts','tmp','.git','composer'))
 fonts=vendor/'mpdf/mpdf/ttfonts';fonts.mkdir()
 for name in ['DejaVuSans.ttf','DejaVuinfo.txt']:shutil.copyfile(up/'vendor/mpdf/mpdf/ttfonts'/name,fonts/name)
 run(['php',str(work/'php-scoper.phar'),'add-prefix','--working-dir='+str(stage),'--prefix=StockControlsPDFVendor','--output-dir='+str(output),'--php-version=7.4','--no-config','--no-interaction','vendor'])
 for p in list(output.rglob('*')):
  if p.is_file() and (p.name=='autoload.php' or p.suffix in ['.json','.xml','.neon','.sh','.asc','.pubkey']):p.unlink()
 # Runtime packages must not expose upstream development entry points or test code.
 # Match directory names without case sensitivity (PSR uses Test, not tests).
 # Preserve license/attribution files even if upstream places them in such a folder.
 removed=set()
 for p in list(output.rglob('*')):
  if not p.is_file():continue
  relative=p.relative_to(output)
  license_notice=p.name.casefold().startswith(('license','copying','copyright','notice')) or p.name=='DejaVuinfo.txt'
  if not license_notice and (relative.as_posix().casefold() in UNUSED_ENTRY_POINTS or any(part.casefold() in DEVELOPMENT_DIRS for part in relative.parts[:-1])):
   removed.add(relative.as_posix());p.unlink()
 assert removed==REMOVED_DEVELOPMENT_FILES,('Review changed upstream development-file inventory',sorted(removed))
 # These upstream tags declare MIT and list authors only in composer.json; neither
 # ships a standalone LICENSE. Preserve that exact metadata as readable text.
 for name in ['mpdf/psr-http-message-shim','mpdf/psr-log-aware-trait']:
  original=up/'vendor'/name/'composer.json';metadata=json.loads(original.read_text())
  assert metadata['name']==name and metadata['license']=='MIT' and metadata['authors']
  assert not any(p.name.lower().startswith('license') for p in original.parent.iterdir())
  shutil.copyfile(original,output/name/'UPSTREAM-COMPOSER.txt')
 # Scoper cannot infer dynamically concatenated tag class names; pin the two exact literals.
 for name in ['mpdf/mpdf/src/Tag.php','mpdf/mpdf/src/Tag/Tag.php']:
  p=output/name;s=p.read_text();old="'Mpdf\\Tag\\\\'";assert s.count(old)==1;s=s.replace(old,"'StockControlsPDFVendor\\Mpdf\\Tag\\\\'");p.write_text(s)
 # Keep Symfony's optional Normalizer stub private even when ext-intl is absent.
 p=output/'symfony/polyfill-intl-normalizer/Normalizer.php';s=p.read_text();s,n=re.subn(r'(?<![A-Za-z0-9_\\])\\Normalizer\b',lambda _:r'\StockControlsPDFVendor\Normalizer',s);assert n==9,n;s=s.replace("'Normalizer::NONE'","'StockControlsPDFVendor\\Normalizer::NONE'");p.write_text(s)
 p=output/'symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php';s=p.read_text();s,n=re.subn(r"\\class_alias\([^\n]+\);\n",'',s);assert n==1,n;p.write_text(s)
 for p in sorted(output.rglob('*'),key=lambda x:len(x.parts),reverse=True):
  if p.is_dir() and not any(p.iterdir()):p.rmdir()
 # These optional global function bootstraps are unused by the private static Normalizer adapter.
 for name in ['bootstrap.php','bootstrap80.php']:(output/'symfony/polyfill-intl-normalizer'/name).unlink()
 # PHP7.4 does not resolve function calls in a parent namespace. Keep the shipped polyfills private.
 p=output/'mpdf/mpdf/src/functions.php';s=p.read_text();s=s.replace("!\\function_exists('str_starts_with') && ", '').replace("!\\function_exists('str_ends_with') && ",'');p.write_text(s)
 for name,want in [('Color/ColorConverter.php',1),('AssetFetcher.php',2),('Http/CurlHttpClient.php',1)]:
  p=output/'mpdf/mpdf/src'/name;s=p.read_text();assert s.count('str_starts_with(')==want;s=s.replace('str_starts_with(',r'\StockControlsPDFVendor\str_starts_with(');p.write_text(s)
 shutil.copyfile(HERE/'NOTICE.txt',output/'NOTICE.txt')
 files=snapshot(output);(work/'result.json').write_text(json.dumps({'files':files,'count':len(files),'removed_development_files':sorted(removed)},indent=2)+'\n');print(json.dumps({'output':str(output),'files':len(files)}))
if __name__=='__main__':main()
```

Verify the output
-----------------
From the extracted stockcontrols-inventory-operations directory, compare the
complete rebuilt tree against the shipped vendor tree with this Python command.
Both member names and file bytes must match. It writes nothing to either tree.

python3 - /path/to/new-rebuilt-pdf-vendor vendor/stockcontrols-pdf <<'PY'
from pathlib import Path
import hashlib, sys
def snapshot(root):
    root = Path(root)
    if not root.is_dir():
        raise SystemExit('Missing directory: ' + str(root))
    return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest()
            for p in root.rglob('*') if p.is_file()}
built, shipped = map(snapshot, sys.argv[1:])
if built != shipped:
    raise SystemExit('PDF vendor member names or SHA256 hashes differ')
print(str(len(built)) + ' PDF vendor files match exactly')
PY

This recipe reproduces the PDF vendor subtree only. It does not package the
whole Free/Pro release, start WordPress/database services, or run store actions.
It does not test every host or replace the plugin's functional qualification.
Source files are provided in readable form. Licenses are described in NOTICE.txt
and retained with the corresponding third-party components; original
StockControls code remains GPL-2.0-or-later and the combined Free package is GPLv2.
