From ce689f75e0dab0e76afc857cee962baebf821836 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Mon, 17 Feb 2025 14:16:37 +0100 Subject: [PATCH 01/29] Pythonic (and ocrd>=3.0) rewrite --- .gitignore | 52 ++ ocrd-pagetopdf | 184 ------- {ptp => ocrd_pagetopdf}/PageToPdf.jar | Bin ocrd_pagetopdf/__init__.py | 0 ocrd_pagetopdf/cli.py | 9 + {ptp => ocrd_pagetopdf}/copyright.txt | 0 {ptp => ocrd_pagetopdf}/data/AletheiaSans.ttf | Bin {ptp => ocrd_pagetopdf}/lib/PrimaBasic.jar | Bin {ptp => ocrd_pagetopdf}/lib/PrimaDla.jar | Bin {ptp => ocrd_pagetopdf}/lib/PrimaIo.jar | Bin {ptp => ocrd_pagetopdf}/lib/PrimaMaths.jar | Bin .../lib/itextpdf-5.5.2.jar | Bin ocrd_pagetopdf/multipagepdf.py | 75 +++ .../ocrd-tool.json | 27 +- ocrd_pagetopdf/processor.py | 451 ++++++++++++++++++ ptp/extract-imagefilename.py | 13 - ptp/help/Usage.txt | 30 -- ptp/multipagepdf.py | 95 ---- ptp/negative2zero.py | 34 -- pyproject.toml | 75 +++ requirements.txt | 3 + 21 files changed, 677 insertions(+), 371 deletions(-) create mode 100644 .gitignore delete mode 100755 ocrd-pagetopdf rename {ptp => ocrd_pagetopdf}/PageToPdf.jar (100%) create mode 100644 ocrd_pagetopdf/__init__.py create mode 100644 ocrd_pagetopdf/cli.py rename {ptp => ocrd_pagetopdf}/copyright.txt (100%) rename {ptp => ocrd_pagetopdf}/data/AletheiaSans.ttf (100%) rename {ptp => ocrd_pagetopdf}/lib/PrimaBasic.jar (100%) rename {ptp => ocrd_pagetopdf}/lib/PrimaDla.jar (100%) rename {ptp => ocrd_pagetopdf}/lib/PrimaIo.jar (100%) rename {ptp => ocrd_pagetopdf}/lib/PrimaMaths.jar (100%) rename {ptp => ocrd_pagetopdf}/lib/itextpdf-5.5.2.jar (100%) create mode 100644 ocrd_pagetopdf/multipagepdf.py rename ocrd-tool.json => ocrd_pagetopdf/ocrd-tool.json (85%) create mode 100644 ocrd_pagetopdf/processor.py delete mode 100644 ptp/extract-imagefilename.py delete mode 100644 ptp/help/Usage.txt delete mode 100644 ptp/multipagepdf.py delete mode 100644 ptp/negative2zero.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4074f70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# vim tmp +*.swp +*.swo + +# emacs bkup +*~ diff --git a/ocrd-pagetopdf b/ocrd-pagetopdf deleted file mode 100755 index 4f64e6a..0000000 --- a/ocrd-pagetopdf +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env bash -set -eu -set -o pipefail -# showing cmd execution on std (deactivated for productive use) -#set -x - -which ocrd >/dev/null 2>/dev/null || { echo "ocrd not in \$PATH. Panicking"; exit 1; } - -SHAREDIR="$(cd "$(dirname "$0")" >/dev/null && pwd)" -SCRIPT_NAME="${0##*/}" - -MIMETYPE_PAGE=$(ocrd bashlib constants MIMETYPE_PAGE) - -FIFO=$(mktemp -u) -function backout { - kill $(jobs -p) - wait &>/dev/null - exec 3>&- - rm -f $FIFO - exit 1 -} - -main () { - # Load ocrd bashlib functions - # shellcheck source=../core/ocrd/bashlib/lib.bash - source $(ocrd bashlib filename) - - ocrd__minversion 2.58.1 - - # Describe calling script to lib.bash - ocrd__wrap "$SHAREDIR/ocrd-tool.json" "$SCRIPT_NAME" "$@" - - # Parameters - local negative2zero output_extension - declare -a parameters=() - if [ -z ${params['textequiv_level']:=} ]; then - ocrd log warning "If you want to add a text layer, please set parameter 'textequiv_level' accordingly!" - else - # first letter is sufficient (case does not matter) - parameters+=(-text-source ${params['textequiv_level']:0:1}) - fi - parameters+=(${params['font']:+-font} ${params['font']:-}) - if [ ${params['outlines']:=} ]; then - parameters+=(-outlines ${params['outlines']:0:1}) - fi - - case ${params['negative2zero']} in - [tT]rue|1) - negative2zero=1 - ;; - *) - negative2zero=0 - esac - - parameters+=(${params['script-args']}) - output_extension=${params['ext']} - - cd "${ocrd__argv[working_dir]}" - local page_id in_file_grp img_file_grp out_file_grp - page_id=${ocrd__argv[page_id]:-} - mets="$(basename ${ocrd__argv[mets_file]})" - IFS=',' read -ra in_grps <<< "${ocrd__argv[input_file_grp]}" - in_file_grp="${in_grps[0]}" - img_file_grp="${in_grps[1]:-}" - if [ -z "$img_file_grp" ]; then - ocrd log warning "Without a second input file group for images, the original imageFilename will be used" - fi - out_file_grp=${ocrd__argv[output_file_grp]} - - mkfifo $FIFO - trap backout ERR - - bulk_options=( -r '(?P[^ ]+) (?P[^ ]+) (?P[^ ]+) (?P.*)') - bulk_options+=( -G '{{ grp }}' -m application/pdf -g '{{ page }}' -i '{{ file }}' -S '{{ url }}') - if [[ "${ocrd__argv[overwrite]}" == true ]];then - bulk_options+=( --force ) - fi - declare -a workspace_options - workspace_options=( -m "${mets}" ) - if [[ -n "${ocrd__argv[mets_server_url]}" ]];then - workspace_options+=( -U "${ocrd__argv[mets_server_url]}" ) - fi - ocrd workspace "${workspace_options[@]}" bulk-add "${bulk_options[@]}" - <$FIFO & - exec 3>$FIFO - - local zeros=0000 - # Download the files and do the conversion - for ((n=0; n<${#ocrd__files[*]}; n++)); do - local in_file=($(ocrd__input_file $n local_filename)) - local in_id=($(ocrd__input_file $n ID)) - local in_mimetype=($(ocrd__input_file $n mimetype)) - local in_pageId=($(ocrd__input_file $n pageId)) - local out_id="$(ocrd__input_file $n outputFileId)" - local out_file="$out_file_grp/${out_id}.xml" - - declare -a options=(${parameters[*]}) - ocrd log info "processing page '${in_pageId}'" - - if ! test -f "${in_file#file://}"; then - ocrd log error "input file \"${in_file#file://}\" (ID=${in_id} pageId=${in_pageId} MIME=${in_mimetype}) is not on disk" - continue - fi - mkdir -p $out_file_grp - ocrd log info "found ${in_mimetype} file '${in_file}'" - - if [ -n "$img_file_grp" ]; then - # multiple fileGrps: ocrd__input_file() is n-ary - img_file="${in_file[1]}" - img_mime="${in_mimetype[1]}" - else - img_file="${in_file}" - img_mime="${in_mimetype}" - fi - - # Rework coords in PAGE - if ((negative2zero)); then - local tmpfile - tmpfile=$(mktemp --tmpdir ocrd-pagetopdf.XXXXXX) - python3 "$SHAREDIR/ptp/negative2zero.py" "$in_file" $tmpfile - in_file=$tmpfile - fi - options+=(-xml "$in_file") - - if [ "$img_mime" = "$MIMETYPE_PAGE" ]; then - # we could use xsltproc or xmlstarlet for this - # (but that would add another dependency) - img_file=$(python3 "$SHAREDIR/ptp/extract-imagefilename.py" "$img_file") - fi - - if ! test -f "$img_file"; then - ocrd log error "No image file '$img_file' for $in_id (pageId=$in_pageId)" - continue - fi - options+=(-image "$img_file") - - # Output filename - local out_id="${in_id//$in_file_grp/$out_file_grp}" - if [ "x$out_id" = "x$in_id" ]; then - out_id=${out_file_grp}_${zeros:0:$((4-${#n}))}$n - fi - local out_file="$out_file_grp/${out_id}$output_extension" - options+=(-pdf "$out_file") - - ocrd log info "found image file '$img_file'" - if ! output=$(java -jar "$SHAREDIR/ptp/PageToPdf.jar" "${options[@]}" 2>&1); then - ocrd log error "PageToPdf failed for ID $in_id (pageId=$in_pageId): $output" - continue - elif ! [ -f "$out_file" -a -s "$out_file" ]; then - ocrd log error "PageToPdf result is empty for ID $in_id (pageId=$in_pageId): $output" - continue - fi - - if ((negative2zero)); then - rm $tmpfile - fi - - ocrd log info "adding output PDF file '$out_file'" - # Add the output file to METS - # if [ -n "$in_pageId" ]; then - # options=( -g $in_pageId ) - # else - # options=() - # fi - # if [[ "${ocrd__argv[overwrite]}" = true ]]; then - # options+=( --force ) - # fi - # options+=( -G $out_file_grp - # -m application/pdf - # -i "$out_id" - # "$out_file" ) - # ocrd workspace add "${options[@]}" - echo "${out_file_grp}" "${in_pageId}" "${out_id}" "${out_file}" >&3 - done - exec 3>&- - rm -f $FIFO - wait - - if [ ${params['multipage']:=} ]; then - python3 "$SHAREDIR/ptp/multipagepdf.py" "$mets" "$out_file_grp" "$page_id" "${params['multipage']}" "${params['pagelabel']}" "${ocrd__argv['overwrite']}" - fi -} - - -main "$@" diff --git a/ptp/PageToPdf.jar b/ocrd_pagetopdf/PageToPdf.jar similarity index 100% rename from ptp/PageToPdf.jar rename to ocrd_pagetopdf/PageToPdf.jar diff --git a/ocrd_pagetopdf/__init__.py b/ocrd_pagetopdf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ocrd_pagetopdf/cli.py b/ocrd_pagetopdf/cli.py new file mode 100644 index 0000000..ea3c861 --- /dev/null +++ b/ocrd_pagetopdf/cli.py @@ -0,0 +1,9 @@ +import click + +from ocrd.decorators import ocrd_cli_options, ocrd_cli_wrap_processor +from .processor import PAGE2PDF + +@click.command() +@ocrd_cli_options +def ocrd_pagetopdf(*args, **kwargs): + return ocrd_cli_wrap_processor(PAGE2PDF, *args, **kwargs) diff --git a/ptp/copyright.txt b/ocrd_pagetopdf/copyright.txt similarity index 100% rename from ptp/copyright.txt rename to ocrd_pagetopdf/copyright.txt diff --git a/ptp/data/AletheiaSans.ttf b/ocrd_pagetopdf/data/AletheiaSans.ttf similarity index 100% rename from ptp/data/AletheiaSans.ttf rename to ocrd_pagetopdf/data/AletheiaSans.ttf diff --git a/ptp/lib/PrimaBasic.jar b/ocrd_pagetopdf/lib/PrimaBasic.jar similarity index 100% rename from ptp/lib/PrimaBasic.jar rename to ocrd_pagetopdf/lib/PrimaBasic.jar diff --git a/ptp/lib/PrimaDla.jar b/ocrd_pagetopdf/lib/PrimaDla.jar similarity index 100% rename from ptp/lib/PrimaDla.jar rename to ocrd_pagetopdf/lib/PrimaDla.jar diff --git a/ptp/lib/PrimaIo.jar b/ocrd_pagetopdf/lib/PrimaIo.jar similarity index 100% rename from ptp/lib/PrimaIo.jar rename to ocrd_pagetopdf/lib/PrimaIo.jar diff --git a/ptp/lib/PrimaMaths.jar b/ocrd_pagetopdf/lib/PrimaMaths.jar similarity index 100% rename from ptp/lib/PrimaMaths.jar rename to ocrd_pagetopdf/lib/PrimaMaths.jar diff --git a/ptp/lib/itextpdf-5.5.2.jar b/ocrd_pagetopdf/lib/itextpdf-5.5.2.jar similarity index 100% rename from ptp/lib/itextpdf-5.5.2.jar rename to ocrd_pagetopdf/lib/itextpdf-5.5.2.jar diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py new file mode 100644 index 0000000..9660ac7 --- /dev/null +++ b/ocrd_pagetopdf/multipagepdf.py @@ -0,0 +1,75 @@ +from __future__ import absolute_import + +from typing import Dict, List, Optional +import os.path +from tempfile import TemporaryDirectory +from logging import getLogger +import subprocess + +from ocrd_models.constants import NAMESPACES as NS + +def get_metadata(mets): + title = mets._tree.getroot().find('.//mods:title', NS) + subtitle = mets._tree.getroot().find('.//mods:subtitle', NS) + title = title.text if title is not None else "" + title += "Subtitle: "+subtitle.text if subtitle else "" + publisher = mets._tree.getroot().find('.//mods:publisher', NS) + author = mets._tree.getroot().find('.//mods:creator', NS) + return { + 'Author': author.text if author is not None else "", + 'Title': title, + 'Keywords': publisher.text+" (Publisher)" if publisher is not None else "", + } + +def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): + inputfiles = [] + pagelabels = [] + for f in mets.find_files(mimetype='application/pdf', fileGrp=filegrp, pageId=page_ids or None): + # ignore existing multipage PDFs + if f.pageId: + inputfiles.append(f.local_filename) + if pagelabel != "pagenumber": + pagelabels.append(getattr(f, pagelabel, "")) + return inputfiles, pagelabels + +def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None) -> str: + pdfmarks = os.path.join(directory, 'pdfmarks.ps') + with open(pdfmarks, 'w') as marks: + if metadata: + marks.write("[ ") + for metakey, metaval in metadata.items(): + if metaval: + marks.write(f"/{metakey} ({metaval})\n") + marks.write("/DOCINFO pdfmark\n") + if pagelabels: + marks.write("[{Catalog} <<\n\ + /PageLabels <<\n\ + /Nums [\n") + for idx, pagelabel in enumerate(pagelabels): + #marks.write(f"1 << /S /D /St 10>>\n") + marks.write(f"{idx} << /P ({pagelabel}) >>\n") + marks.write("] >> >> /PUT pdfmark") + return pdfmarks + +def pdfmerge(inputfiles: List[str], outputfile: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None, log=None) -> bool: + if log is None: + log = getLogger('ocrd.processor.pagetopdf') + inputfiles = ' '.join(inputfiles) + with TemporaryDirectory() as tmpdir: + pdfmarks = create_pdfmarks(tmpdir, pagelabels, metadata) + result = subprocess.run( + "gs -sDEVICE=pdfwrite -dNOPAUSE -dBATCH -dSAFER " + f"-sOutputFile={outputfile} {inputfiles} {pdfmarks}", + shell=True, text=True, capture_output=True, + # does not show stdout and stderr: + #check=True, + encoding="utf-8", + ) + if result.stdout: + log.debug("gs stdout: %s", result.stdout) + if result.stderr: + log.warning("gs stderr: %s", result.stderr) + if result.returncode != 0: + log.error("gs command for multipage PDF %s failed - %s", outputfile, result) + return False + return True diff --git a/ocrd-tool.json b/ocrd_pagetopdf/ocrd-tool.json similarity index 85% rename from ocrd-tool.json rename to ocrd_pagetopdf/ocrd-tool.json index 0f245a4..0d28314 100644 --- a/ocrd-tool.json +++ b/ocrd_pagetopdf/ocrd-tool.json @@ -1,6 +1,7 @@ { - "version": "0.0.2", - "git_url": "https://github.com/jkamlah/ocrd_pagetopdf", + "version": "0.1.0", + "git_url": "https://github.com/UB-Mannheim/ocrd_pagetopdf", + "dockerhub": "ocrd/pagetopdf", "tools": { "ocrd-pagetopdf": { "executable": "ocrd-pagetopdf", @@ -11,12 +12,8 @@ "steps": [ "postprocessing/format-conversion" ], - "input_file_grp": [ - "OCR-D-OCR-PAGE" - ], - "output_file_grp": [ - "OCR-D-OCR-PDF" - ], + "input_file_grp_cardinality": 1, + "output_file_grp_cardinality": 1, "parameters": { "font": { "description": "Font file to be used in PDF file. If unset, AletheiaSans.ttf is used. (Make sure to pick a font which covers all glyphs!)", @@ -69,13 +66,13 @@ "type": "string", "default": "pageId", "enum": [ - "pagenumber", - "pageId", - "basename", - "basename_without_extension", - "local_filename", - "ID", - "url" + "pagenumber", + "pageId", + "basename", + "basename_without_extension", + "local_filename", + "ID", + "url" ] }, "script-args": { diff --git a/ocrd_pagetopdf/processor.py b/ocrd_pagetopdf/processor.py new file mode 100644 index 0000000..db5f6a4 --- /dev/null +++ b/ocrd_pagetopdf/processor.py @@ -0,0 +1,451 @@ +from __future__ import absolute_import + +from typing import Optional, get_args +import itertools +import os +from shutil import copyfile +from tempfile import TemporaryDirectory +import subprocess + +import numpy as np +from scipy.sparse.csgraph import minimum_spanning_tree +from shapely.geometry import Polygon, LineString +from shapely.geometry.polygon import orient +from shapely import set_precision +from shapely.ops import unary_union, nearest_points + +from ocrd import Processor, Workspace +from ocrd.mets_server import ClientSideOcrdMets +from ocrd_models.ocrd_file import OcrdFileType +from ocrd_utils import ( + coordinates_of_segment, + points_from_polygon, + polygon_from_points, + resource_filename, + make_file_id, + REGEX_FILE_ID, + config, +) +from ocrd_models.ocrd_page import ( + TextRegionType, + BorderType, + PageType, + OcrdPage, + to_xml +) +from ocrd_modelfactory import page_from_file +from ocrd_validators.page_validator import ( + CoordinateConsistencyError, + CoordinateValidityError, + PageValidator +) + +from . import multipagepdf + + +class PAGE2PDF(Processor): + + @property + def executable(self): + return 'ocrd-pagetopdf' + + def setup(self): + self.cliparams = ["java", "-jar", str(resource_filename('ocrd_pagetopdf', 'PageToPdf.jar'))] + if not self.parameter['textequiv_level']: + self.logger.warning("If you want to add a text layer, set 'textequiv_level'!") + else: + self.cliparams.extend([ + "-text-source", self.parameter['textequiv_level'][0] + ]) + if self.parameter['font']: + self.cliparams.extend([ + "-font", self.resolve_resource(self.parameter['font']) + ]) + if self.parameter['outlines']: + self.cliparams.extend([ + "-outlines", self.parameter['outlines'][0] + ]) + self.cliparams.extend(self.parameter['script-args'].split()) + + def process_workspace(self, workspace: Workspace) -> None: + super().process_workspace(workspace) + if self.parameter['multipage']: + output_file_id = self.parameter['multipage'] + if not REGEX_FILE_ID.fullmatch(output_file_id): + output_file_id = output_file_id.replace(':', '_') + output_file_id = re.sub(r'^([^a-zA-Z_])', r'id_\1', output_file_id) + output_file_id = re.sub(r'[^\w.-]', r'', output_file_id) + output_file_path = os.path.join(self.output_file_grp, self.parameter['multipage']) + if not output_file_path.lower().endswith('.pdf'): + output_file_path += '.pdf' + self.logger.info("aggregating multi-page PDF to %s", output_file_path) + pdffiles, pagelabels = multipagepdf.read_from_mets( + workspace.mets, self.output_file_grp, self.page_id, + pagelabel=self.parameter['pagelabel'] + ) + if not pdffiles: + self.logger.warning("No single-page files, skipping multi-page output '%s'", output_file_path) + return + if isinstance(workspace.mets, ClientSideOcrdMets): + # we cannot use METS Server for MODS queries + # instantiate (read and parse) METS from disk (read-only, metadata are constant) + ws = Workspace(workspace.resolver, workspace.directory, + mets_basename=os.path.basename(workspace.mets_target)) + metadata = multipagepdf.get_metadata(ws.mets) + else: + metadata = multipagepdf.get_metadata(workspace.mets) + if multipagepdf.pdfmerge( + pdffiles, output_file_path, + pagelabels=pagelabels, metadata=metadata, + log=self.logger): + workspace.add_file( + file_id=output_file_id, + file_grp=self.output_file_grp, + local_filename=output_file_path, + mimetype="application/pdf", + page_id=None, + force=config.OCRD_EXISTING_OUTPUT == 'OVERWRITE', + ) + + def process_page_file(self, input_file: OcrdFileType) -> None: + """Converts all pages of the document to PDF + + Open and deserialize PAGE input files and their respective images, + then go to the page hierarchy level... FIXME + """ + assert isinstance(input_file, get_args(OcrdFileType)) + page_id = input_file.pageId + self._base_logger.info("processing page %s", page_id) + self._base_logger.debug(f"parsing file {input_file.ID} for page {page_id}") + try: + page_ = page_from_file(input_file) + assert isinstance(page_, OcrdPage) + input_pcgts = page_ + except ValueError as err: + # not PAGE and not an image to generate PAGE for + self._base_logger.error(f"non-PAGE input for page {page_id}: {err}") + return + output_file_id = make_file_id(input_file, self.output_file_grp) + output_file = next(self.workspace.mets.find_files(ID=output_file_id), None) + if output_file and config.OCRD_EXISTING_OUTPUT != 'OVERWRITE': + # short-cut avoiding useless computation: + raise FileExistsError( + f"A file with ID=={output_file_id} already exists {output_file} and neither force nor ignore are set" + ) + output_file_path = os.path.join(self.output_file_grp, output_file_id + self.parameter['ext']) + + # --- equivalent of process_page_pcgts vvv + pcgts = input_pcgts + page = pcgts.get_Page() + # get maximally annotated image + page_image, page_coords, _ = self.workspace.image_from_page( + page, page_id, + #feature_filter=feature_filter, + #feature_selector=feature_selector + ) + # get matching PAGE (transform all coordinates) + page.set_Border(None) + page.set_orientation(None) + for region in page.get_AllRegions(): + region_polygon = coordinates_of_segment(region, page_image, page_coords) + region.get_Coords().set_points(points_from_polygon(region_polygon)) + if isinstance(region, TextRegionType): + for line in region.get_TextLine(): + line_polygon = coordinates_of_segment(line, page_image, page_coords) + line.get_Coords().set_points(points_from_polygon(line_polygon)) + for word in line.get_Word(): + word_polygon = coordinates_of_segment(word, page_image, page_coords) + word.get_Coords().set_points(points_from_polygon(word_polygon)) + for glyph in word.get_Glyph(): + glyph_polygon = coordinates_of_segment(glyph, page_image, page_coords) + glyph.get_Coords().set_points(points_from_polygon(glyph_polygon)) + if self.parameter['negative2zero']: + self._repair(pcgts) + + # write image and PAGE into temporary directory and convert + with TemporaryDirectory(suffix=page_id) as tmpdir: + img_path = os.path.join(tmpdir, "image.png") + page_path = os.path.join(tmpdir, "page.xml") + out_path = os.path.join(tmpdir, "page.pdf") # self.parameter['ext'] + with open(img_path, "wb") as img_file: + page_image.save(img_file, format="PNG") + with open(page_path, "w") as page_file: + page_file.write(to_xml(pcgts)) + converter = ' '.join(self.cliparams + ["-xml", page_path, "-image", img_path, "-pdf", out_path]) + # execute command pattern + self.logger.debug("Running command: '%s'", converter) + # pylint: disable=subprocess-run-check + result = subprocess.run(converter, shell=True, text=True, capture_output=True, + # does not show stdout and stderr: + #check=True, + encoding="utf-8") + if result.stdout: + self.logger.debug("PageToPdf for %s stdout: %s", page_id, result.stdout) + if result.stderr: + self.logger.warning("PageToPdf for %s stderr: %s", page_id, result.stderr) + if result.returncode != 0: + raise Exception("PageToPdf command failed", result) + if not os.path.exists(out_path): + raise Exception("PageToPdf result is empty", result) + os.makedirs(self.output_file_grp, exist_ok=True) + copyfile(out_path, output_file_path) + # --- equivalent of process_page_pcgts ^^^ + + # add to METS + self.workspace.add_file( + file_id=output_file_id, + file_grp=self.output_file_grp, + page_id=page_id, + local_filename=output_file_path, + mimetype='application/pdf', + ) + + def _repair(self, pcgts): + # instead of ad-hoc repairs, just run the PAGE validator, + # then proceed as in ocrd-segment-repair + report = PageValidator.validate( + ocrd_page=pcgts, + page_textequiv_consistency='off', + check_baseline=False) + page = pcgts.get_Page() + for error in report.errors: + if isinstance(error, (CoordinateConsistencyError,CoordinateValidityError)): + if error.tag == 'Page': + element = page.get_Border() + elif error.tag.endswith('Region'): + element = next((region + for region in page.get_AllRegions() + if region.id == error.ID), None) + elif error.tag == 'TextLine': + element = next((line + for region in page.get_AllRegions(classes=['Text']) + for line in region.get_TextLine() + if line.id == error.ID), None) + elif error.tag == 'Word': + element = next((word + for region in page.get_AllRegions(classes=['Text']) + for line in region.get_TextLine() + for word in line.get_Word() + if word.id == error.ID), None) + elif error.tag == 'Glyph': + element = next((glyph + for region in page.get_AllRegions(classes=['Text']) + for line in region.get_TextLine() + for word in line.get_Word() + for glyph in word.get_Glyph() + if glyph.id == error.ID), None) + else: + self.logger.error("Unrepairable error for unknown segment type: %s", + str(error)) + continue + if not element: + self.logger.error("Unrepairable error for unknown segment element: %s", + str(error)) + continue + try: + if isinstance(error, CoordinateConsistencyError): + ensure_consistent(element) + else: + ensure_valid(element) + except Exception as e: + self.logger.error(str(e)) # exc_info=e + continue + self.logger.info("Fixed %s for %s '%s'", error.__class__.__name__, + error.tag, error.ID) + else: + self.logger.warning("Ignoring other validation error: %s", str(error)) + + +# remaineder is from ocrd_segment: + +def join_polygons(polygons, scale=20): + """construct concave hull (alpha shape) from input polygons by connecting their pairwise nearest points""" + # ensure input polygons are simply typed and all oriented equally + polygons = [orient(poly) + for poly in itertools.chain.from_iterable( + [poly.geoms + if poly.geom_type in ['MultiPolygon', 'GeometryCollection'] + else [poly] + for poly in polygons])] + npoly = len(polygons) + if npoly == 1: + return polygons[0] + # find min-dist path through all polygons (travelling salesman) + pairs = itertools.combinations(range(npoly), 2) + dists = np.zeros((npoly, npoly), dtype=float) + for i, j in pairs: + dist = polygons[i].distance(polygons[j]) + if dist < 1e-5: + dist = 1e-5 # if pair merely touches, we still need to get an edge + dists[i, j] = dist + dists[j, i] = dist + dists = minimum_spanning_tree(dists, overwrite=True) + # add bridge polygons (where necessary) + for prevp, nextp in zip(*dists.nonzero()): + prevp = polygons[prevp] + nextp = polygons[nextp] + nearest = nearest_points(prevp, nextp) + bridgep = orient(LineString(nearest).buffer(max(1, scale/5), resolution=1), -1) + polygons.append(bridgep) + jointp = unary_union(polygons) + assert jointp.geom_type == 'Polygon', jointp.wkt + # follow-up calculations will necessarily be integer; + # so anticipate rounding here and then ensure validity + jointp2 = set_precision(jointp, 1.0) + if jointp2.geom_type != 'Polygon' or not jointp2.is_valid: + jointp2 = Polygon(np.round(jointp.exterior.coords)) + jointp2 = make_valid(jointp2) + assert jointp2.geom_type == 'Polygon', jointp2.wkt + return jointp2 + +def make_valid(polygon): + """Ensures shapely.geometry.Polygon object is valid by repeated rearrangement/simplification/enlargement.""" + points = list(polygon.exterior.coords) + # try by re-arranging points + for split in range(1, len(points)): + if polygon.is_valid or polygon.simplify(polygon.area).is_valid: + break + # simplification may not be possible (at all) due to ordering + # in that case, try another starting point + polygon = Polygon(points[-split:]+points[:-split]) + # try by simplification + for tolerance in range(int(polygon.area + 1.5)): + if polygon.is_valid: + break + # simplification may require a larger tolerance + polygon = polygon.simplify(tolerance + 1) + # try by enlarging + for tolerance in range(1, int(polygon.area + 2.5)): + if polygon.is_valid: + break + # enlargement may require a larger tolerance + polygon = polygon.buffer(tolerance) + assert polygon.is_valid, polygon.wkt + return polygon + +def merge_poly(poly1, poly2): + poly = poly1.union(poly2) + if poly.geom_type == 'MultiPolygon': + #poly = poly.convex_hull + poly = join_polygons(poly.geoms) + if poly.minimum_clearance < 1.0: + poly = Polygon(np.round(poly.exterior.coords)) + poly = make_valid(poly) + return poly + +def clip_poly(poly1, poly2): + poly = poly1.intersection(poly2) + if poly.is_empty or poly.area == 0.0: + return None + if poly.geom_type == 'GeometryCollection': + # heterogeneous result: filter zero-area shapes (LineString, Point) + poly = unary_union([geom for geom in poly.geoms if geom.area > 0]) + if poly.geom_type == 'MultiPolygon': + # homogeneous result: construct convex hull to connect + #poly = poly.convex_hull + poly = join_polygons(poly.geoms) + if poly.minimum_clearance < 1.0: + # follow-up calculations will necessarily be integer; + # so anticipate rounding here and then ensure validity + poly = Polygon(np.round(poly.exterior.coords)) + poly = make_valid(poly) + return poly + +def page_poly(page): + return Polygon([[0, 0], + [0, page.get_imageHeight()], + [page.get_imageWidth(), page.get_imageHeight()], + [page.get_imageWidth(), 0]]) + +# same as polygon_for_parent pattern in other processors +def ensure_consistent(child, at_parent=False): + """Make segment coordinates fit into parent coordinates. + + Ensure that the coordinate polygon of ``child`` is fully + contained in the coordinate polygon of its parent. + + \b + To achieve that when necessary, either + - enlarge the parent to the union of both, + if ``at_parent`` + - shrink the child to the intersection of both, + otherwise. + + In any case, ensure the resulting polygon is valid. + + If the parent is at page level, and there is no Border, + then use the page frame (and assume `at_parent=False`). + + If ``child`` is at page level, and there is a Border, + then use the page frame as parent (and assume `at_parent=False`). + """ + if isinstance(child, PageType): + if not child.get_Border(): + return + childp = Polygon(polygon_from_points(child.get_Border().get_Coords().points)) + parentp = page_poly(child) + at_parent = False # clip to page frame + parent = child + elif isinstance(child, BorderType): + childp = Polygon(polygon_from_points(child.get_Coords().points)) + parentp = page_poly(child.parent_object_) + at_parent = False # clip to page frame + parent = child.parent_object_ + else: + points = child.get_Coords().points + polygon = polygon_from_points(points) + parent = child.parent_object_ + childp = Polygon(polygon) + if isinstance(parent, PageType): + if parent.get_Border(): + parentp = Polygon(polygon_from_points(parent.get_Border().get_Coords().points)) + else: + parentp = page_poly(parent) + at_parent = False # clip to page frame + else: + parentp = Polygon(polygon_from_points(parent.get_Coords().points)) + # ensure input coords have valid paths (without self-intersection) + # (this can happen when shapes valid in floating point are rounded) + childp = make_valid(childp) + parentp = make_valid(parentp) + if childp.within(parentp): + return + # enlargement/clipping is necessary + if at_parent: + # enlarge at parent + unionp = merge_poly(childp, parentp) + polygon = unionp.exterior.coords[:-1] # keep open + points = points_from_polygon(polygon) + parent.get_Coords().set_points(points) + else: + # clip to parent + interp = clip_poly(childp, parentp) + if interp is None: + raise Exception("Segment '%s' does not intersect its parent '%s'" % ( + child.id, parent.id)) + polygon = interp.exterior.coords[:-1] # keep open + points = points_from_polygon(polygon) + child.get_Coords().set_points(points) + +def ensure_valid(element): + changed = False + coords = element.get_Coords() + points = coords.points + polygon = polygon_from_points(points) + array = np.array(polygon, int) + if array.min() < 0: + array = np.maximum(0, array) + changed = True + if array.shape[0] < 3: + array = np.concatenate([ + array, array[::-1] + 1]) + changed = True + polygon = array.tolist() + poly = Polygon(polygon) + if not poly.is_valid: + poly = make_valid(poly) + polygon = poly.exterior.coords[:-1] + changed = True + if changed: + points = points_from_polygon(polygon) + coords.set_points(points) diff --git a/ptp/extract-imagefilename.py b/ptp/extract-imagefilename.py deleted file mode 100644 index ac526f5..0000000 --- a/ptp/extract-imagefilename.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import absolute_import - -import sys - -from ocrd_models.ocrd_page_generateds import parse - -def get_imagefilename(inputfile): - pcgts = parse(inputfile, silence=True) - page = pcgts.get_Page() - print(page.get_imageFilename()) - -if __name__== "__main__": - get_imagefilename(sys.argv[1]) diff --git a/ptp/help/Usage.txt b/ptp/help/Usage.txt deleted file mode 100644 index 47dc536..0000000 --- a/ptp/help/Usage.txt +++ /dev/null @@ -1,30 +0,0 @@ -PAGE to PDF Converter - -PRImA Research Lab, University of Salford, UK - -Arguments: - - -xml Single PAGE XML file to convert or - a folder with multiple XML files. - - -image Single document image (.tif, .png, .jpg) or - a folder with multiple images (the filenames - have to match the filenames of the XMLs). - - -pdf Output PDF file. - - -text-source Optional. Add hidden text layer, using text from: - Text region objects R - Text line objects T - Word objects W - Glyph objects G - - -outlines Optional. Add layer with object outlines. - One or a combination of (no spaces)): - Regions R - Text lines T - Words W - Glyphs G - - -font Optional. TrueType font to be used. - See included font 'data/AletheiaSans.ttf' \ No newline at end of file diff --git a/ptp/multipagepdf.py b/ptp/multipagepdf.py deleted file mode 100644 index 40e3e85..0000000 --- a/ptp/multipagepdf.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import absolute_import - -from pathlib import Path -import sys -import subprocess - -from ocrd_models import OcrdMets -from ocrd_utils.logging import getLogger, initLogging -from ocrd_utils import atomic_write -from ocrd_models.constants import NAMESPACES as NS - -def get_metadata(mets): - title = mets._tree.getroot().find('.//mods:title', NS) - subtitle = mets._tree.getroot().find('.//mods:subtitle', NS) - title = title.text if title is not None else "" - title += "Subtitle: "+subtitle.text if subtitle else "" - publisher = mets._tree.getroot().find('.//mods:publisher', NS) - author = mets._tree.getroot().find('.//mods:creator', NS) - return {'Author':author.text if author is not None else "", - 'Title': title, - 'Keywords': publisher.text+" (Publisher)" if publisher is not None else ""} - -def read_from_mets(metsfile, filegrp, page_ids, outputfile, pagelabel='pageId', overwrite=False): - overwrite = overwrite == 'true' - mets = OcrdMets(filename=metsfile) - inputfiles = [] - pagelabels = [] - metadata = get_metadata(mets) - for f in mets.find_files(mimetype='application/pdf', fileGrp=filegrp, pageId=(page_ids or None)): - # ignore multipaged pdfs - if f.pageId: - inputfiles.append(f.local_filename) - if pagelabel != "pagenumber": - pagelabels.append(getattr(f, pagelabel,"")) - log = getLogger('processor.pagetopdf') - if not inputfiles: - log.warning("No PDF input files for merging %s", outputfile) - return None - if pdfmerge(inputfiles, outputfile, pagelabels=pagelabels, metadata=metadata): - mets.add_file(filegrp, mimetype='application/pdf', ID=outputfile, - url=str(Path(filegrp).joinpath(outputfile+'.pdf')), - force=overwrite) - with atomic_write(metsfile) as f: - f.write(mets.to_xml(xmllint=True).decode('utf-8')) - -def create_pdfmarks(pdfdir, pagelabels=None, metadata=None): - pdfmarks = pdfdir.joinpath('pdfmarks.ps') - with pdfmarks.open('w') as marks: - if metadata: - marks.write("[ ") - for kwarg in ['Title', 'Author', 'Keywords']: - if kwarg in metadata: - marks.write(f"/{kwarg} ({metadata[kwarg]})\n") - marks.write("/DOCINFO pdfmark\n") - if pagelabels: - marks.write("[{Catalog} <<\n\ - /PageLabels <<\n\ - /Nums [\n") - for idx, pagelabel in enumerate(pagelabels): - #marks.write(f"1 << /S /D /St 10>>\n") - marks.write(f"{idx} << /P ({pagelabel}) >>\n") - marks.write("] >> >> /PUT pdfmark") - return pdfmarks - -def pdfmerge(inputfiles, outputfile, pagelabels=None, metadata=None, store_tmp=False): - log = getLogger('processor.pagetopdf') - if isinstance(inputfiles, str): - inputfiles = inputfiles.split(",") - log.info("Merging PDFs..") - pdfmarks = None - try: - pdfdir = Path(inputfiles[0]).parent - pdfmarks = create_pdfmarks(pdfdir, pagelabels, metadata) - stdout = subprocess.check_output( - f"gs -sDEVICE=pdfwrite \ - -dNOPAUSE -dBATCH -dSAFER \ - -sOutputFile={pdfdir.joinpath(outputfile+'.pdf')} \ - {' '.join(inputfiles)}\ - {pdfmarks}", shell=True, - stderr=subprocess.STDOUT, - # give us str instead of bytes: - universal_newlines=True) - for line in stdout.split('\n'): - log.debug(line) - return True - except Exception: - log.exception(f"Couldn't merge the pdf files.") - return False - finally: - if pdfmarks and not store_tmp: - pdfmarks.unlink() - -if __name__=='__main__': - initLogging() - read_from_mets(*sys.argv[1:]) diff --git a/ptp/negative2zero.py b/ptp/negative2zero.py deleted file mode 100644 index 330a27c..0000000 --- a/ptp/negative2zero.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import absolute_import - -import sys - -from ocrd_models.ocrd_page_generateds import parse -from ocrd_models.ocrd_page import to_xml - -def update_points(points): - points = points.split(" ") - for idx, point in enumerate(points): - if "-" in point: - points[idx] = "0,0" - else: - points[idx] = point - return " ".join(points) - -def negative2zero(inputfile, outputfile): - print("Setting negative coords to zero..") - pcgts = parse(inputfile, silence=True) - page = pcgts.get_Page() - for attr in dir(page): - if "get_" in attr and "Region" in attr: - for regiondata in getattr(page,attr)(): - if attr == "get_TextRegion": - for textline in regiondata.get_TextLine(): - textcoords = textline.get_Coords() - textcoords.set_points(update_points(textcoords.get_points())) - regcoords = regiondata.get_Coords() - regcoords.set_points(update_points(regcoords.get_points())) - content = to_xml(pcgts) - with open(outputfile,"w") as fout: - fout.write(content) -if __name__=="__main__": - negative2zero(sys.argv[1],sys.argv[2]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6de830 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,75 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel", "setuptools-ocrd"] + +[project] +name = "ocrd_pagetopdf" +authors = [ + {name = "Jan Kamlah", email = "jan.kamlah@uni-mannheim.de"}, + {name = "Robert Sachunsky", email = "robert.sachunsky@slub-dresden.de"}, +] +description = "OCR-D wrapper for prima-pagetopdf" +readme = "README.md" +license.file = "LICENSE" +requires-python = ">=3.8" +keywords = ["ocr", "ocr-d", "page-xml"] + +dynamic = ["version", "dependencies"] + +# https://pypi.org/classifiers/ +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Science/Research", + "Intended Audience :: Other Audience", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Text Processing", +] + +[project.scripts] +ocrd-pagetopdf = "ocrd_pagetopdf.cli:ocrd_pagetopdf" + +[project.urls] +Homepage = "https://github.com/UB-Mannheim/ocrd_pagetopdf" +Repository = "https://github.com/UB-Mannheim/ocrd_pagetopdf.git" + + +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + +[tool.setuptools.package-data] +"*" = ["PageToPdf.jar", "*.ttf", "*.txt", "*.otf", "ocrd-tool.json"] +"ocrd_pagetopdf.lib" = ["*.jar"] + +[tool.mypy] +plugins = ["numpy.typing.mypy_plugin"] +ignore_missing_imports = true +strict = true +disallow_subclassing_any = false +# ❗ error: Class cannot subclass "Processor" (has type "Any") +disallow_any_generics = false +disallow_untyped_defs = false +disallow_untyped_calls = false + +[tool.coverage.run] +branch = true +source = [ + "ocrd_pagetopdf" +] +concurrency = [ + "thread", + "multiprocessing" +] + +[tool.coverage.report] +exclude_also = [ + "if self\\.debug", + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] +ignore_errors = true +omit = [ + "ocrd_pagetopdf/cli.py" +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1841f1d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +ocrd>=3.0 +scipy + From c2f874d0fdfc0e8d39ba3d8d4b9be2299cc502ad Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Mon, 17 Feb 2025 18:14:36 +0100 Subject: [PATCH 02/29] re-add ocrd-tool.json as symlink --- ocrd-tool.json | 1 + 1 file changed, 1 insertion(+) create mode 120000 ocrd-tool.json diff --git a/ocrd-tool.json b/ocrd-tool.json new file mode 120000 index 0000000..7935d0d --- /dev/null +++ b/ocrd-tool.json @@ -0,0 +1 @@ +ocrd_pagetopdf/ocrd-tool.json \ No newline at end of file From 19717372c2898f2171e00137135c24702fc8df10 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 18 Feb 2025 15:03:58 +0100 Subject: [PATCH 03/29] add params image_feature_filter/selector --- ocrd_pagetopdf/ocrd-tool.json | 14 ++++++++++++-- ocrd_pagetopdf/processor.py | 8 +++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/ocrd_pagetopdf/ocrd-tool.json b/ocrd_pagetopdf/ocrd-tool.json index 0d28314..0960301 100644 --- a/ocrd_pagetopdf/ocrd-tool.json +++ b/ocrd_pagetopdf/ocrd-tool.json @@ -15,6 +15,16 @@ "input_file_grp_cardinality": 1, "output_file_grp_cardinality": 1, "parameters": { + "image_feature_selector": { + "type": "string", + "default": "", + "description": "comma-separated list of required image features (e.g. binarized,despeckled,cropped,deskewed,rotated-90)" + }, + "image_feature_filter": { + "type": "string", + "default": "", + "description": "comma-separated list of forbidden image features (e.g. binarized,despeckled,cropped,deskewed,rotated-90)" + }, "font": { "description": "Font file to be used in PDF file. If unset, AletheiaSans.ttf is used. (Make sure to pick a font which covers all glyphs!)", "type": "string", @@ -47,7 +57,7 @@ ] }, "negative2zero": { - "description": "Set all negative box values to 0", + "description": "Repair invalid or inconsistent coordinates before trying to convert.", "type": "boolean", "default": false }, @@ -57,7 +67,7 @@ "default": ".pdf" }, "multipage": { - "description": "Merge all PDFs into one multipage file. The value is used as filename for the pdf.", + "description": "Merge all PDFs into one multipage file. The value is used as filename for the PDF.", "type": "string", "default": "" }, diff --git a/ocrd_pagetopdf/processor.py b/ocrd_pagetopdf/processor.py index db5f6a4..01df1bc 100644 --- a/ocrd_pagetopdf/processor.py +++ b/ocrd_pagetopdf/processor.py @@ -137,11 +137,13 @@ def process_page_file(self, input_file: OcrdFileType) -> None: # --- equivalent of process_page_pcgts vvv pcgts = input_pcgts page = pcgts.get_Page() - # get maximally annotated image + # get maximally annotated image matching requested features + feature_selector = self.parameter['image_feature_selector'] + feature_filter = self.parameter['image_feature_filter'] page_image, page_coords, _ = self.workspace.image_from_page( page, page_id, - #feature_filter=feature_filter, - #feature_selector=feature_selector + feature_filter=feature_filter, + feature_selector=feature_selector ) # get matching PAGE (transform all coordinates) page.set_Border(None) From 6a82bfbdac00737eaf07b7c6d2462cebfcb8d0ea Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 18 Feb 2025 15:04:55 +0100 Subject: [PATCH 04/29] fix outline coordinates (by updating ' Page/@image_*') --- ocrd_pagetopdf/processor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ocrd_pagetopdf/processor.py b/ocrd_pagetopdf/processor.py index 01df1bc..a13f3c4 100644 --- a/ocrd_pagetopdf/processor.py +++ b/ocrd_pagetopdf/processor.py @@ -161,6 +161,9 @@ def process_page_file(self, input_file: OcrdFileType) -> None: for glyph in word.get_Glyph(): glyph_polygon = coordinates_of_segment(glyph, page_image, page_coords) glyph.get_Coords().set_points(points_from_polygon(glyph_polygon)) + page.set_imageWidth(page_image.width) + page.set_imageHeight(page_image.height) + page.set_imageFilename("image.png") if self.parameter['negative2zero']: self._repair(pcgts) From f161ef5acddbf48a465ff57cce242110ba76e2ba Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 18 Feb 2025 15:05:45 +0100 Subject: [PATCH 05/29] multipage: raise instead of log when gs fails --- ocrd_pagetopdf/multipagepdf.py | 6 ++---- ocrd_pagetopdf/processor.py | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index 9660ac7..cce7253 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -51,7 +51,7 @@ def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, meta marks.write("] >> >> /PUT pdfmark") return pdfmarks -def pdfmerge(inputfiles: List[str], outputfile: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None, log=None) -> bool: +def pdfmerge(inputfiles: List[str], outputfile: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None, log=None) -> None: if log is None: log = getLogger('ocrd.processor.pagetopdf') inputfiles = ' '.join(inputfiles) @@ -70,6 +70,4 @@ def pdfmerge(inputfiles: List[str], outputfile: str, pagelabels: Optional[List[s if result.stderr: log.warning("gs stderr: %s", result.stderr) if result.returncode != 0: - log.error("gs command for multipage PDF %s failed - %s", outputfile, result) - return False - return True + raise Exception("gs command for multipage PDF %s failed" % outputfile, result.args, result.stdout, result.stderr) diff --git a/ocrd_pagetopdf/processor.py b/ocrd_pagetopdf/processor.py index a13f3c4..4a5da19 100644 --- a/ocrd_pagetopdf/processor.py +++ b/ocrd_pagetopdf/processor.py @@ -94,18 +94,18 @@ def process_workspace(self, workspace: Workspace) -> None: metadata = multipagepdf.get_metadata(ws.mets) else: metadata = multipagepdf.get_metadata(workspace.mets) - if multipagepdf.pdfmerge( - pdffiles, output_file_path, - pagelabels=pagelabels, metadata=metadata, - log=self.logger): - workspace.add_file( - file_id=output_file_id, - file_grp=self.output_file_grp, - local_filename=output_file_path, - mimetype="application/pdf", - page_id=None, - force=config.OCRD_EXISTING_OUTPUT == 'OVERWRITE', - ) + multipagepdf.pdfmerge( + pdffiles, output_file_path, + pagelabels=pagelabels, metadata=metadata, + log=self.logger) + workspace.add_file( + file_id=output_file_id, + file_grp=self.output_file_grp, + local_filename=output_file_path, + mimetype="application/pdf", + page_id=None, + force=config.OCRD_EXISTING_OUTPUT == 'OVERWRITE', + ) def process_page_file(self, input_file: OcrdFileType) -> None: """Converts all pages of the document to PDF From ae8d86e4fa70a6b5d71b1bb69ba53ecba6d63fa1 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 18 Feb 2025 15:07:17 +0100 Subject: [PATCH 06/29] multipage metadata: utilise more DOCINFO (Document Information Dictionary) fields --- ocrd_pagetopdf/multipagepdf.py | 63 +++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index cce7253..ab6448a 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from datetime import datetime from typing import Dict, List, Optional import os.path from tempfile import TemporaryDirectory @@ -9,16 +10,57 @@ from ocrd_models.constants import NAMESPACES as NS def get_metadata(mets): - title = mets._tree.getroot().find('.//mods:title', NS) - subtitle = mets._tree.getroot().find('.//mods:subtitle', NS) - title = title.text if title is not None else "" - title += "Subtitle: "+subtitle.text if subtitle else "" - publisher = mets._tree.getroot().find('.//mods:publisher', NS) - author = mets._tree.getroot().find('.//mods:creator', NS) + mets = mets._tree.getroot() + metshdr = mets.find('.//mets:metsHdr', NS) + createdate = metshdr.attrib.get('CREATEDATE', '') if metshdr is not None else '' + modifieddate = metshdr.attrib.get('LASTMODDATE', '') if metshdr is not None else '' + creator = mets.xpath('.//mets:agent[@ROLE="CREATOR"]/mets:name', namespaces=NS) + titlestring = "" + titleinfo = mets.find('.//mods:titleInfo', NS) + if titleinfo is not None: + title = titleinfo.find('.//mods:title', NS) + titlestring += title.text if title is not None else "" + for subtitle in titleinfo.findall('.//mods:subtitle', NS): + titlestring += " - " + subtitle.text if subtitle else "" + part = titleinfo.find('.//mods:partNumber', NS) + titlestring += " - " + part.text if part else "" + part = titleinfo.find('.//mods:partName', NS) + titlestring += " - " + part.text if part else "" + author = (mets.xpath('.//mods:name[mods:role/text()="aut"]' + '/mods:namePart[@type="family" or @type="given"]', namespaces=NS) + + mets.xpath('.//mods:name[mods:role/text()="cre"]' + '/mods:namePart[@type="family" or @type="given"]', namespaces=NS)) + author = next((part.text for part in author + if part.attrib["type"] == "given"), "") \ + + next((" " + part.text for part in author + if part.attrib["type"] == "family"), "") + origin = mets.find('.//mods:originInfo', NS) + if origin is not None: + publisher = origin.find('.//mods:publisher', NS) + publdate = origin.find('.//mods:dateIssued', NS) + digidate = origin.find('.//mods:dateCaptured', NS) + publisher = publisher.text + " (Publisher)" if publisher is not None else "" + publdate = publdate.text if publdate is not None else "" + digidate = digidate.text if digidate is not None else "" + def iso8601toiso32000(datestring): + date = datetime.fromisoformat(datestring) + offset = date.utcoffset() + tz_hours, tz_seconds = divmod(offset.seconds if offset else 0, 3600) + tz_minutes = tz_seconds // 60 + datestring = date.strftime("%Y%m%d%H%M%S") + datestring += f"Z{tz_hours}'{tz_minutes}'" + return datestring + access = mets.find('.//mods:accessCondition', NS) return { - 'Author': author.text if author is not None else "", - 'Title': title, - 'Keywords': publisher.text+" (Publisher)" if publisher is not None else "", + 'Author': author, + 'Title': titlestring, + 'Keywords': publisher, + 'Description': "", + 'Creator': creator[0].text if len(creator) else "", + 'Published': publdate, + # only via XMP: 'Access condition': access.text if access is not None else "", + 'CreationDate': iso8601toiso32000(createdate) if createdate else "", + 'ModDate': iso8601toiso32000(modifieddate) if modifieddate else "", } def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): @@ -41,6 +83,9 @@ def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, meta if metaval: marks.write(f"/{metakey} ({metaval})\n") marks.write("/DOCINFO pdfmark\n") + # fixme: add XMP-embedded metadata: + # - DC (https://www.loc.gov/standards/mods/mods-dcsimple.html) + # - MODS-RDF (https://www.loc.gov/standards/mods/modsrdf/primer-2.html) if pagelabels: marks.write("[{Catalog} <<\n\ /PageLabels <<\n\ From 14b2f9fc9e10c59b1375a71bb03e68a32a9b3b96 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 18 Feb 2025 18:50:48 +0100 Subject: [PATCH 07/29] check if any text exists on textequiv_level, warn if not --- ocrd_pagetopdf/processor.py | 85 ++++++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 24 deletions(-) diff --git a/ocrd_pagetopdf/processor.py b/ocrd_pagetopdf/processor.py index 4a5da19..a846201 100644 --- a/ocrd_pagetopdf/processor.py +++ b/ocrd_pagetopdf/processor.py @@ -165,7 +165,9 @@ def process_page_file(self, input_file: OcrdFileType) -> None: page.set_imageHeight(page_image.height) page.set_imageFilename("image.png") if self.parameter['negative2zero']: - self._repair(pcgts) + self._repair(pcgts, page_id) + if self.parameter['textequiv_level']: + self._inspect(pcgts, page_id) # write image and PAGE into temporary directory and convert with TemporaryDirectory(suffix=page_id) as tmpdir: @@ -205,7 +207,7 @@ def process_page_file(self, input_file: OcrdFileType) -> None: mimetype='application/pdf', ) - def _repair(self, pcgts): + def _repair(self, pcgts, page_id): # instead of ad-hoc repairs, just run the PAGE validator, # then proceed as in ocrd-segment-repair report = PageValidator.validate( @@ -213,39 +215,37 @@ def _repair(self, pcgts): page_textequiv_consistency='off', check_baseline=False) page = pcgts.get_Page() + regions = page.get_AllRegions() + textregions = page.get_AllRegions(classes=['Text']) + lines = [line for region in textregions + for line in region.get_TextLine()] + words = [word for line in lines + for word in line.get_Word()] + glyphs = [glyph for word in words + for glyph in word.get_Glyph()] for error in report.errors: if isinstance(error, (CoordinateConsistencyError,CoordinateValidityError)): if error.tag == 'Page': element = page.get_Border() elif error.tag.endswith('Region'): - element = next((region - for region in page.get_AllRegions() + element = next((region for region in regions if region.id == error.ID), None) elif error.tag == 'TextLine': - element = next((line - for region in page.get_AllRegions(classes=['Text']) - for line in region.get_TextLine() + element = next((line for line in lines if line.id == error.ID), None) elif error.tag == 'Word': - element = next((word - for region in page.get_AllRegions(classes=['Text']) - for line in region.get_TextLine() - for word in line.get_Word() + element = next((word for word in words if word.id == error.ID), None) elif error.tag == 'Glyph': - element = next((glyph - for region in page.get_AllRegions(classes=['Text']) - for line in region.get_TextLine() - for word in line.get_Word() - for glyph in word.get_Glyph() + element = next((glyph for glyph in glyphs if glyph.id == error.ID), None) else: - self.logger.error("Unrepairable error for unknown segment type: %s", - str(error)) + self.logger.error("Unrepairable error for unknown segment type '%s' on page %s", + str(error), page_id) continue if not element: - self.logger.error("Unrepairable error for unknown segment element: %s", - str(error)) + self.logger.error("Unrepairable error for unknown segment element '%s' on page %s", + str(error), page_id) continue try: if isinstance(error, CoordinateConsistencyError): @@ -253,13 +253,43 @@ def _repair(self, pcgts): else: ensure_valid(element) except Exception as e: - self.logger.error(str(e)) # exc_info=e + self.logger.error("Cannot fix %s for %s '%s' on page %s: %s", # exc_info=e + error.__class__.__name__, error.tag, error.ID, page_id, str(e)) continue - self.logger.info("Fixed %s for %s '%s'", error.__class__.__name__, - error.tag, error.ID) + self.logger.info("Fixed %s for %s '%s' on page %s", + error.__class__.__name__, error.tag, error.ID, page_id) else: - self.logger.warning("Ignoring other validation error: %s", str(error)) + self.logger.warning("Ignoring other validation error on page %s: %s", + page_id, str(error)) + def _inspect(self, pcgts, page_id): + level = self.parameter['textequiv_level'] + if not level: + return + regions = pcgts.get_Page().get_AllRegions(classes=['Text']) + if level == 'region': + if any(page_element_unicode0(region) + for region in regions): + return + lines = [line for region in regions + for line in region.get_TextLine()] + if level == 'line': + if any(page_element_unicode0(line) + for line in lines): + return + words = [word for line in lines + for word in line.get_Word()] + if level == 'word': + if any(page_element_unicode0(word) + for word in words): + return + glyphs = [glyph for word in words + for glyph in word.get_Glyph()] + if level == 'glyph': + if any(page_element_unicode0(glyph) + for glyph in glyphs): + return + self.logger.warning("no text at %s level on page %s", level, page_id) # remaineder is from ocrd_segment: @@ -454,3 +484,10 @@ def ensure_valid(element): if changed: points = points_from_polygon(polygon) coords.set_points(points) + +def page_element_unicode0(element): + """Get Unicode string of the first text result.""" + if element.get_TextEquiv(): + return element.get_TextEquiv()[0].Unicode or '' + else: + return '' From 6076a9634e71afcb7905f7f685bc37cadd260682 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 18 Feb 2025 18:51:33 +0100 Subject: [PATCH 08/29] add parameter 'multipage_only', removing single-page files finally --- ocrd_pagetopdf/multipagepdf.py | 8 ++++--- ocrd_pagetopdf/ocrd-tool.json | 39 +++++++++++++++++++--------------- ocrd_pagetopdf/processor.py | 6 +++++- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index ab6448a..a986ae0 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -64,15 +64,17 @@ def iso8601toiso32000(datestring): } def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): - inputfiles = [] + file_names = [] pagelabels = [] + file_ids = [] for f in mets.find_files(mimetype='application/pdf', fileGrp=filegrp, pageId=page_ids or None): # ignore existing multipage PDFs if f.pageId: - inputfiles.append(f.local_filename) + file_names.append(f.local_filename) if pagelabel != "pagenumber": pagelabels.append(getattr(f, pagelabel, "")) - return inputfiles, pagelabels + file_ids.append(f.ID) + return file_names, pagelabels, file_ids def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None) -> str: pdfmarks = os.path.join(directory, 'pdfmarks.ps') diff --git a/ocrd_pagetopdf/ocrd-tool.json b/ocrd_pagetopdf/ocrd-tool.json index 0960301..03b2f92 100644 --- a/ocrd_pagetopdf/ocrd-tool.json +++ b/ocrd_pagetopdf/ocrd-tool.json @@ -57,9 +57,9 @@ ] }, "negative2zero": { - "description": "Repair invalid or inconsistent coordinates before trying to convert.", - "type": "boolean", - "default": false + "description": "Repair invalid or inconsistent coordinates before trying to convert.", + "type": "boolean", + "default": false }, "ext": { "description": "Output filename extension", @@ -67,23 +67,28 @@ "default": ".pdf" }, "multipage": { - "description": "Merge all PDFs into one multipage file. The value is used as filename for the PDF.", - "type": "string", - "default": "" + "description": "Merge all PDFs into one multipage file. The value is used as METS file ID and file basename for the PDF.", + "type": "string", + "default": "" + }, + "multipage_only": { + "description": "When producing a `multipage`, do not add single-page files into the output fileGrp (but use a temporary directory for them).", + "type": "boolean", + "default": false }, "pagelabel": { - "description": "Parameter for 'multipage': Set the page information, which will be used as pagelabel. Default is 'pageId', e.g. the option 'pagenumber' will create numbered pagelabel consecutively", - "type": "string", - "default": "pageId", + "description": "Parameter for 'multipage': Set the page information, which will be used as pagelabel. Default is 'pageId', e.g. the option 'pagenumber' will create numbered pagelabel consecutively", + "type": "string", + "default": "pageId", "enum": [ - "pagenumber", - "pageId", - "basename", - "basename_without_extension", - "local_filename", - "ID", - "url" - ] + "pagenumber", + "pageId", + "basename", + "basename_without_extension", + "local_filename", + "ID", + "url" + ] }, "script-args": { "description": "Extra arguments to PageToPdf (see https://github.com/PRImA-Research-Lab/prima-page-to-pdf)", diff --git a/ocrd_pagetopdf/processor.py b/ocrd_pagetopdf/processor.py index a846201..6081a48 100644 --- a/ocrd_pagetopdf/processor.py +++ b/ocrd_pagetopdf/processor.py @@ -79,7 +79,7 @@ def process_workspace(self, workspace: Workspace) -> None: if not output_file_path.lower().endswith('.pdf'): output_file_path += '.pdf' self.logger.info("aggregating multi-page PDF to %s", output_file_path) - pdffiles, pagelabels = multipagepdf.read_from_mets( + pdffiles, pagelabels, pdffile_ids = multipagepdf.read_from_mets( workspace.mets, self.output_file_grp, self.page_id, pagelabel=self.parameter['pagelabel'] ) @@ -106,6 +106,10 @@ def process_workspace(self, workspace: Workspace) -> None: page_id=None, force=config.OCRD_EXISTING_OUTPUT == 'OVERWRITE', ) + if self.parameter['multipage_only']: + for pdffile_id in pdffile_ids: + # FIXME: does not work with METS Server! + workspace.remove_file(pdffile_id) def process_page_file(self, input_file: OcrdFileType) -> None: """Converts all pages of the document to PDF From 9d3e30bf011c832d4698dbb55d20b27d156fd11c Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 19 Feb 2025 18:59:23 +0100 Subject: [PATCH 09/29] title metadata: avoid relatedItem --- ocrd_pagetopdf/multipagepdf.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index a986ae0..69752d2 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -16,8 +16,10 @@ def get_metadata(mets): modifieddate = metshdr.attrib.get('LASTMODDATE', '') if metshdr is not None else '' creator = mets.xpath('.//mets:agent[@ROLE="CREATOR"]/mets:name', namespaces=NS) titlestring = "" - titleinfo = mets.find('.//mods:titleInfo', NS) - if titleinfo is not None: + titleinfos = mets.findall('.//mods:titleInfo', NS) + for titleinfo in titleinfos: + if titleinfo.getparent().tag == "{%s}relatedItem" % NS['mods']: + continue title = titleinfo.find('.//mods:title', NS) titlestring += title.text if title is not None else "" for subtitle in titleinfo.findall('.//mods:subtitle', NS): @@ -26,6 +28,7 @@ def get_metadata(mets): titlestring += " - " + part.text if part else "" part = titleinfo.find('.//mods:partName', NS) titlestring += " - " + part.text if part else "" + break author = (mets.xpath('.//mods:name[mods:role/text()="aut"]' '/mods:namePart[@type="family" or @type="given"]', namespaces=NS) + mets.xpath('.//mods:name[mods:role/text()="cre"]' From 69786ceaa76e0b1b48fb919368b92630719aa70b Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 19 Feb 2025 18:59:41 +0100 Subject: [PATCH 10/29] producer metadata: use pkg name and version --- ocrd_pagetopdf/multipagepdf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index 69752d2..0ef599e 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from importlib.metadata import version from datetime import datetime from typing import Dict, List, Optional import os.path @@ -60,6 +61,7 @@ def iso8601toiso32000(datestring): 'Keywords': publisher, 'Description': "", 'Creator': creator[0].text if len(creator) else "", + 'Producer': __package__ + " v" + version(__package__), 'Published': publdate, # only via XMP: 'Access condition': access.text if access is not None else "", 'CreationDate': iso8601toiso32000(createdate) if createdate else "", From 7d8e141be8c4479397eb79fc7e37dae9073777c2 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 19 Feb 2025 19:00:53 +0100 Subject: [PATCH 11/29] pagelabel parameter: add pagelabel value (using @ORDER/LABEL) --- ocrd_pagetopdf/multipagepdf.py | 23 ++++++++++++++++++++++- ocrd_pagetopdf/ocrd-tool.json | 3 ++- ocrd_pagetopdf/processor.py | 16 ++++++++-------- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index 0ef599e..dcdd7c4 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -72,11 +72,32 @@ def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): file_names = [] pagelabels = [] file_ids = [] + if pagelabel == "pagelabel": + pages = mets.get_physical_pages(for_pageIds=page_ids, return_divs=True) for f in mets.find_files(mimetype='application/pdf', fileGrp=filegrp, pageId=page_ids or None): # ignore existing multipage PDFs if f.pageId: file_names.append(f.local_filename) - if pagelabel != "pagenumber": + if pagelabel == "pagenumber": + pass + elif pagelabel == "pagelabel": + for page in pages: + if page.get('ID') == f.pageId: + order = page.get('ORDER') or '' + orderlabel = page.get('ORDERLABEL') or '' + label = page.get('LABEL') or '' + if label and orderlabel: + pagelabels.append(orderlabel + ' - ' + label) + elif orderlabel: + pagelabels.append(orderlabel) + elif label: + pagelabels.append(label) + elif order: + pagelabels.append(order) + else: + pagelabels.append("") + break + else: pagelabels.append(getattr(f, pagelabel, "")) file_ids.append(f.ID) return file_names, pagelabels, file_ids diff --git a/ocrd_pagetopdf/ocrd-tool.json b/ocrd_pagetopdf/ocrd-tool.json index 03b2f92..e657b4b 100644 --- a/ocrd_pagetopdf/ocrd-tool.json +++ b/ocrd_pagetopdf/ocrd-tool.json @@ -77,11 +77,12 @@ "default": false }, "pagelabel": { - "description": "Parameter for 'multipage': Set the page information, which will be used as pagelabel. Default is 'pageId', e.g. the option 'pagenumber' will create numbered pagelabel consecutively", + "description": "Parameter for 'multipage': Set the labels used as page outlines.\n\n - 'pageId': physical page ID,\n\n - 'pagenumber': use consecutive numbers,\n\n - 'pagelabel': use '@ORDERLABEL - @LABEL',\n\n - 'basename': use the name of the input file,\n\n - 'local_filename': use the href relative path of the input file,\n\n - 'url': use the href URL of the input file,\n\n - 'ID': use the file ID of the input file", "type": "string", "default": "pageId", "enum": [ "pagenumber", + "pagelabel", "pageId", "basename", "basename_without_extension", diff --git a/ocrd_pagetopdf/processor.py b/ocrd_pagetopdf/processor.py index 6081a48..c0fe47f 100644 --- a/ocrd_pagetopdf/processor.py +++ b/ocrd_pagetopdf/processor.py @@ -121,14 +121,6 @@ def process_page_file(self, input_file: OcrdFileType) -> None: page_id = input_file.pageId self._base_logger.info("processing page %s", page_id) self._base_logger.debug(f"parsing file {input_file.ID} for page {page_id}") - try: - page_ = page_from_file(input_file) - assert isinstance(page_, OcrdPage) - input_pcgts = page_ - except ValueError as err: - # not PAGE and not an image to generate PAGE for - self._base_logger.error(f"non-PAGE input for page {page_id}: {err}") - return output_file_id = make_file_id(input_file, self.output_file_grp) output_file = next(self.workspace.mets.find_files(ID=output_file_id), None) if output_file and config.OCRD_EXISTING_OUTPUT != 'OVERWRITE': @@ -137,6 +129,14 @@ def process_page_file(self, input_file: OcrdFileType) -> None: f"A file with ID=={output_file_id} already exists {output_file} and neither force nor ignore are set" ) output_file_path = os.path.join(self.output_file_grp, output_file_id + self.parameter['ext']) + try: + page_ = page_from_file(input_file) + assert isinstance(page_, OcrdPage) + input_pcgts = page_ + except ValueError as err: + # not PAGE and not an image to generate PAGE for + self._base_logger.error(f"non-PAGE input for page {page_id}: {err}") + return # --- equivalent of process_page_pcgts vvv pcgts = input_pcgts From dda17686c0959e49cfc78494a8139f92749f472d Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Thu, 20 Feb 2025 03:13:11 +0100 Subject: [PATCH 12/29] =?UTF-8?q?add=20ALTO2PDF=20processor=20(converting?= =?UTF-8?q?=20ALTO=E2=86=92PAGE=20first,=20using=202.=20input=20fileGrp=20?= =?UTF-8?q?for=20images,=20no=20parsing/validation/repair)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- ocrd_pagetopdf/PageConverter.jar | Bin 0 -> 15448 bytes ocrd_pagetopdf/alto_processor.py | 116 ++++++++++++++++++ ocrd_pagetopdf/cli.py | 8 +- ocrd_pagetopdf/lib/PrimaText.jar | Bin 0 -> 171127 bytes ocrd_pagetopdf/ocrd-tool.json | 87 ++++++++++++- .../{processor.py => page_processor.py} | 0 pyproject.toml | 1 + 8 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 ocrd_pagetopdf/PageConverter.jar create mode 100644 ocrd_pagetopdf/alto_processor.py create mode 100644 ocrd_pagetopdf/lib/PrimaText.jar rename ocrd_pagetopdf/{processor.py => page_processor.py} (100%) diff --git a/.gitignore b/.gitignore index 4074f70..4267c2e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ dist/ downloads/ eggs/ .eggs/ -lib/ +/lib/ lib64/ parts/ sdist/ diff --git a/ocrd_pagetopdf/PageConverter.jar b/ocrd_pagetopdf/PageConverter.jar new file mode 100644 index 0000000000000000000000000000000000000000..a390d2e4abee9facca2245925a4cae70ee03d502 GIT binary patch literal 15448 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 z zJZVN0_|b$la3e@*Nb#_k?sKu!I12K`cMMl4#6i2cE5(m5+Lt45;(_Rn+{ca#uO{Ra zxET}U+E_hRTAG>n(9v4r$3iq>4}|ETNoe|sLW1Aw-@_%?!Gd06d(I={3mf^BfAUZl zDwD9u7mY>-$Y_l*vYr}bWvz2y%gFS)zN?pR(8j5 zl@(%)R%hDw)!vd)k+}QDuIO3Hn`9+54f`ES5>YYPQ7|~H3Y9aq#8O?;bUURehNxUQ zDVfL;1Ko`k{|@Yp_8u-;w;6%-C0Z zk)MVk8JKgqDnB^D7#UC(N!DWNq#k|-Np<*;snSXlwTX)=2(Sw;%g`iDfs~snO-)e< zS?G=pzj1>Aig)K>y~^bwj|%V{BG*GgbyDLHQo{;VzU=|2TBQ4ykKM`_-%w~1${uvm zl@z6n*5ibWnADsJ(Gwx(cWyzdeubx7OOGQaFLvx`HH0F;k5A;jZVYw+Zx9OZPE@d$ zZ78Yz2z|7;QaP_7W3|31D=AHUY(*&JoykPHMiQ1I=`Hdm+E$jkbUXq9w>!!~(Ml2Z zmXsRRIj$k~4zvp&X;kTBT6Ljz4m{Wx9-fqb`kol6)i^|UaO|npj{tjG?MIc^jY4(5Vh~ zE2y+ZG7d0JpG_n4n%r2MhTI{~OFM{L@zG^|wn)9J(l;j^TFkzY7^Gi)6f|u?y^NI? z_BFVDFYs>HkW%6b-}?t)jT)kt5~7z(i946Ww6GOZqf1+m;FiP0un3tG)U zAK{4=gq;0JR_OAX!y$Y{FBybDq1#>)A)QPp!j~9ixY92~kcT+OWOjNCv+`#tABiP- z*e-g{_W%N9moj3-$ZM1R#4UyZ2Kv`JI?uRsGs;!hO3KRtChV5-fd_3JhjffYhvAq zVhM%T%*0(n?r6CC_v^^)B!F-v_fvfFbq3l6S6@AzmjJ&M{PGZ=!B;8DJYpfX8i3AJ zmK;LDUuAP7!X40vS7b5Ni?q4~h(V$>hM9_Hd^0IIoP5Al|A0`C{sN2&@}FIQzwc>2k`IEKX(EC1@M=x(4YGE_dlTj0|%k3q|-Z`4s+4E&K00|E^X1gU9%{t@FPc$$#bfn{x5rdH!8z z^#>2a?+N<<&huB*)jzZRS=IUn3pwL|$?|74tb#Py?@|~D2=4bs?zeB^j{PtH{ts+9 BhSUH6 literal 0 HcmV?d00001 diff --git a/ocrd_pagetopdf/alto_processor.py b/ocrd_pagetopdf/alto_processor.py new file mode 100644 index 0000000..13d2750 --- /dev/null +++ b/ocrd_pagetopdf/alto_processor.py @@ -0,0 +1,116 @@ +from __future__ import absolute_import + +from typing import Optional, get_args +import os +from shutil import copyfile +from tempfile import TemporaryDirectory +import subprocess + +from ocrd_models.ocrd_file import OcrdFileType +from ocrd_utils import ( + resource_filename, + make_file_id, + config, +) + +from .page_processor import PAGE2PDF + + +class ALTO2PDF(PAGE2PDF): + + @property + def executable(self): + return 'ocrd-altotopdf' + + def setup(self): + super().setup() + self.cliparams2 = ["java", "-jar", str(resource_filename('ocrd_pagetopdf', 'PageConverter.jar'))] + self.cliparams2.extend([ + "-convert-to", "LATEST", + ]) + if self.parameter['negative2zero']: + self.cliparams2.extend([ + "-neg-coords", "toZero", + ]) + + def process_page_file(self, *input_files: Optional[OcrdFileType]) -> None: + """Converts all pages of the document to PDF + + Find ALTO input files in the first fileGrp, + together with the image input files in the second fileGrp, + then first convert ALTO to PAGE in a temporary location. + Next, convert PAGE to PDF in a temporary location. + Copy to the output fileGrp on success and reference those + files in the METS. + + If 'outlines',... + If 'textequiv_level'... + If 'negative2zero'... + + Finally, if 'multipage' is set, then concatenate all files to + a multi-page PDF file, setting 'pagelabels' accordingly. + Reference that file with 'multipage' as ID in the output fileGrp. + If 'multipage_only' is also set, then remove the single-page PDF files afterwards. + """ + assert len(input_files) == 2 + assert isinstance(input_files[0], get_args(OcrdFileType)) + assert isinstance(input_files[1], get_args(OcrdFileType)) + assert input_files[0].mimetype in ['application/alto+xml', 'text/xml'] + assert input_files[1].mimetype.startswith('image/') + alto_file = input_files[0] + image_file = input_files[1] + page_id = alto_file.pageId + self._base_logger.info("processing page %s", page_id) + output_file_id = make_file_id(alto_file, self.output_file_grp) + output_file = next(self.workspace.mets.find_files(ID=output_file_id), None) + if output_file and config.OCRD_EXISTING_OUTPUT != 'OVERWRITE': + # short-cut avoiding useless computation: + raise FileExistsError( + f"A file with ID=={output_file_id} already exists {output_file} and neither force nor ignore are set" + ) + output_file_path = os.path.join(self.output_file_grp, output_file_id + self.parameter['ext']) + + # write image and PAGE into temporary directory and convert + with TemporaryDirectory(suffix=page_id) as tmpdir: + alto_path = os.path.join(tmpdir, "alto.xml") + copyfile(alto_file.local_filename, alto_path) + page_path = os.path.join(tmpdir, "page.xml") + converter2 = ' '.join(self.cliparams2 + ["-source-xml", alto_path, "-target-xml", page_path]) + result = subprocess.run(converter2, shell=True, text=True, capture_output=True, + # does not show stdout and stderr: + #check=True, + encoding="utf-8") + if result.returncode != 0: + raise Exception("PageConverter command failed", result) + if not os.path.exists(page_path) or not os.path.getsize(page_path): + raise Exception("PageConverter result is empty", result) + img_path = os.path.join(tmpdir, "image.png") + copyfile(image_file.local_filename, img_path) + out_path = os.path.join(tmpdir, "page.pdf") # self.parameter['ext'] + converter = ' '.join(self.cliparams + ["-xml", page_path, "-image", img_path, "-pdf", out_path]) + # execute command pattern + self.logger.debug("Running command: '%s'", converter) + # pylint: disable=subprocess-run-check + result = subprocess.run(converter, shell=True, text=True, capture_output=True, + # does not show stdout and stderr: + #check=True, + encoding="utf-8") + if result.stdout: + self.logger.debug("PageToPdf for %s stdout: %s", page_id, result.stdout) + if result.stderr: + self.logger.warning("PageToPdf for %s stderr: %s", page_id, result.stderr) + if result.returncode != 0: + raise Exception("PageToPdf command failed", result) + if not os.path.exists(out_path) or not os.path.getsize(out_path): + raise Exception("PageToPdf result is empty", result) + os.makedirs(self.output_file_grp, exist_ok=True) + copyfile(out_path, output_file_path) + + # add to METS + self.workspace.add_file( + file_id=output_file_id, + file_grp=self.output_file_grp, + page_id=page_id, + local_filename=output_file_path, + mimetype='application/pdf', + ) diff --git a/ocrd_pagetopdf/cli.py b/ocrd_pagetopdf/cli.py index ea3c861..047be25 100644 --- a/ocrd_pagetopdf/cli.py +++ b/ocrd_pagetopdf/cli.py @@ -1,9 +1,15 @@ import click from ocrd.decorators import ocrd_cli_options, ocrd_cli_wrap_processor -from .processor import PAGE2PDF +from .page_processor import PAGE2PDF +from .alto_processor import ALTO2PDF @click.command() @ocrd_cli_options def ocrd_pagetopdf(*args, **kwargs): return ocrd_cli_wrap_processor(PAGE2PDF, *args, **kwargs) + +@click.command() +@ocrd_cli_options +def ocrd_altotopdf(*args, **kwargs): + return ocrd_cli_wrap_processor(ALTO2PDF, *args, **kwargs) diff --git a/ocrd_pagetopdf/lib/PrimaText.jar b/ocrd_pagetopdf/lib/PrimaText.jar new file mode 100644 index 0000000000000000000000000000000000000000..7b35df423e433ad37bbbfae3ff0686116c087ea6 GIT binary patch literal 171127 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

PTOpU6Wq}iVMbrHLGf)-98r%;(^#tM3=^9%t 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 Date: Fri, 21 Feb 2025 02:54:39 +0100 Subject: [PATCH 13/29] multipage: escape/encode strings properly --- ocrd_pagetopdf/multipagepdf.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index dcdd7c4..7f3649e 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -7,6 +7,7 @@ from tempfile import TemporaryDirectory from logging import getLogger import subprocess +import codecs from ocrd_models.constants import NAMESPACES as NS @@ -102,6 +103,21 @@ def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): file_ids.append(f.ID) return file_names, pagelabels, file_ids +def pdfmark_string(string): + try: + _ = string.encode('ascii') + for c, escaped in [('\\', '\\\\'), + ('(', '\\('), + (')', '\\)'), + ('\n', '\\n'), + ('\t', '\\t')]: + string = string.replace(c, escaped) + return '({})'.format(string) + except UnicodeEncodeError: + bstring = codecs.BOM_UTF16_BE + string.encode('utf-16-be') + return '<{}>'.format(''.join('{:02X}'.format(byte) + for byte in bstring)) + def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None) -> str: pdfmarks = os.path.join(directory, 'pdfmarks.ps') with open(pdfmarks, 'w') as marks: @@ -120,7 +136,7 @@ def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, meta /Nums [\n") for idx, pagelabel in enumerate(pagelabels): #marks.write(f"1 << /S /D /St 10>>\n") - marks.write(f"{idx} << /P ({pagelabel}) >>\n") + marks.write(f"{idx} << /P {pdfmark_string(pagelabel)} >>\n") marks.write("] >> >> /PUT pdfmark") return pdfmarks @@ -131,7 +147,7 @@ def pdfmerge(inputfiles: List[str], outputfile: str, pagelabels: Optional[List[s with TemporaryDirectory() as tmpdir: pdfmarks = create_pdfmarks(tmpdir, pagelabels, metadata) result = subprocess.run( - "gs -sDEVICE=pdfwrite -dNOPAUSE -dBATCH -dSAFER " + "gs -q -sDEVICE=pdfwrite -dNOPAUSE -dBATCH -dSAFER " f"-sOutputFile={outputfile} {inputfiles} {pdfmarks}", shell=True, text=True, capture_output=True, # does not show stdout and stderr: From ef46e63d9d800496c29c56abed37e83a859142c9 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 02:55:20 +0100 Subject: [PATCH 14/29] multipage: add MODS as extra XMP file, add logical structMap as bookmark labels --- ocrd_pagetopdf/multipagepdf.py | 149 +++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 36 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index 7f3649e..cc64f86 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -9,64 +9,114 @@ import subprocess import codecs +from lxml import etree from ocrd_models.constants import NAMESPACES as NS +def get_structure(metsroot): + try: + structlink = next(metsroot.iterfind('.//mets:structLink', NS)) + smlinks = {link.get('{http://www.w3.org/1999/xlink}from'): + link.get('{http://www.w3.org/1999/xlink}to') + for link in reversed(structlink.findall('./mets:smLink', NS))} + phymap = next(structmap for structmap in metsroot.iterfind('.//mets:structMap', NS) + if structmap.get('TYPE') == 'PHYSICAL') + topdiv = next(phymap.iterfind('./mets:div', NS)) + pages = {page.get('ID'): page.get('ORDER') or order + for order, page in enumerate(topdiv.findall('./mets:div', NS)) + if page.get('TYPE') == "page"} + logmap = next(structmap for structmap in metsroot.iterfind('.//mets:structMap', NS) + if structmap.get('TYPE') == 'LOGICAL') + topdiv = next(logmap.iterfind('./mets:div', NS)) + # descend to deepest ADM + while topdiv.get('ADMID') is None: + topdiv = topdiv.find('./mets:div', NS) + # we want to dive into multivolume_work, periodical, newspaper, year, month... + # we are looking for issue, volume, monograph, lecture, dossier, act, judgement, study, paper, *_thesis, report, register, file, fragment, manuscript... + innerdiv = topdiv + while (topdiv.find('./mets:div', NS) is not None and + topdiv.find('./mets:div', NS).get('ADMID') is not None): + innerdiv = topdiv + topdiv = topdiv.find('./mets:div', NS) + #for div in innerdiv.iterdescendants('{%s}div' % NS['mets']): + def find_depth(div, depth=0): + return { + 'label': div.get('LABEL') or div.get('ORDERLABEL'), + 'type': div.get('TYPE'), + 'id': div.get('ID'), + 'page': pages.get(smlinks.get(div.get('ID'), ''), ''), + 'depth': depth, + 'subs': [find_depth(subdiv, depth+1) for subdiv in div.findall('./mets:div', NS)] + } + struct = find_depth(innerdiv) + return struct + except StopIteration: + return None + +def iso8601toiso32000(datestring): + date = datetime.fromisoformat(datestring) + offset = date.utcoffset() + tz_hours, tz_seconds = divmod(offset.seconds if offset else 0, 3600) + tz_minutes = tz_seconds // 60 + datestring = date.strftime("%Y%m%d%H%M%S") + datestring += f"Z{tz_hours}'{tz_minutes}'" + return datestring + +def gettext(element): + if element is not None: + return element.text + return "" + def get_metadata(mets): mets = mets._tree.getroot() metshdr = mets.find('.//mets:metsHdr', NS) createdate = metshdr.attrib.get('CREATEDATE', '') if metshdr is not None else '' modifieddate = metshdr.attrib.get('LASTMODDATE', '') if metshdr is not None else '' creator = mets.xpath('.//mets:agent[@ROLE="CREATOR"]/mets:name', namespaces=NS) + creator = creator[0].text if len(creator) else "" + mods = mets.find('.//mods:mods', NS) titlestring = "" - titleinfos = mets.findall('.//mods:titleInfo', NS) + titleinfos = mods.findall('.//mods:titleInfo', NS) for titleinfo in titleinfos: if titleinfo.getparent().tag == "{%s}relatedItem" % NS['mods']: continue - title = titleinfo.find('.//mods:title', NS) - titlestring += title.text if title is not None else "" - for subtitle in titleinfo.findall('.//mods:subtitle', NS): - titlestring += " - " + subtitle.text if subtitle else "" - part = titleinfo.find('.//mods:partNumber', NS) - titlestring += " - " + part.text if part else "" - part = titleinfo.find('.//mods:partName', NS) - titlestring += " - " + part.text if part else "" + titlestring += " - ".join(gettext(titlepart) + for titlepart in ( + [titleinfo.find('.//mods:title', NS)] + + titleinfo.findall('.//mods:subtitle', NS) + + [titleinfo.find('.//mods:partNumber', NS)] + + [titleinfo.find('.//mods:partName', NS)]) + if titlepart is not None) break - author = (mets.xpath('.//mods:name[mods:role/text()="aut"]' + author = (mods.xpath('.//mods:name[mods:role/text()="aut"]' '/mods:namePart[@type="family" or @type="given"]', namespaces=NS) + - mets.xpath('.//mods:name[mods:role/text()="cre"]' + mods.xpath('.//mods:name[mods:role/text()="cre"]' '/mods:namePart[@type="family" or @type="given"]', namespaces=NS)) author = next((part.text for part in author if part.attrib["type"] == "given"), "") \ + next((" " + part.text for part in author if part.attrib["type"] == "family"), "") - origin = mets.find('.//mods:originInfo', NS) + publisher = publdate = digidate = "" + origin = mods.find('.//mods:originInfo', NS) if origin is not None: - publisher = origin.find('.//mods:publisher', NS) - publdate = origin.find('.//mods:dateIssued', NS) - digidate = origin.find('.//mods:dateCaptured', NS) - publisher = publisher.text + " (Publisher)" if publisher is not None else "" - publdate = publdate.text if publdate is not None else "" - digidate = digidate.text if digidate is not None else "" - def iso8601toiso32000(datestring): - date = datetime.fromisoformat(datestring) - offset = date.utcoffset() - tz_hours, tz_seconds = divmod(offset.seconds if offset else 0, 3600) - tz_minutes = tz_seconds // 60 - datestring = date.strftime("%Y%m%d%H%M%S") - datestring += f"Z{tz_hours}'{tz_minutes}'" - return datestring - access = mets.find('.//mods:accessCondition', NS) + publisher = gettext(origin.find('.//mods:publisher', NS)) + publdate = gettext(origin.find('.//mods:dateIssued', NS)) + digidate = gettext(origin.find('.//mods:dateCaptured', NS)) + keywords = publisher + " (Publisher)" if publisher else "" + access = gettext(mods.find('.//mods:accessCondition', NS)) return { 'Author': author, 'Title': titlestring, - 'Keywords': publisher, - 'Description': "", - 'Creator': creator[0].text if len(creator) else "", + 'Keywords': keywords, + 'Creator': creator, 'Producer': __package__ + " v" + version(__package__), 'Published': publdate, - # only via XMP: 'Access condition': access.text if access is not None else "", + 'Digitized': digidate, 'CreationDate': iso8601toiso32000(createdate) if createdate else "", 'ModDate': iso8601toiso32000(modifieddate) if modifieddate else "", + # not part of DOCINFO: + 'Perms': access, + 'MODS': etree.tostring(mods, pretty_print=True, encoding="utf-8").decode("utf-8"), + 'TOC': get_structure(mets) } def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): @@ -122,14 +172,41 @@ def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, meta pdfmarks = os.path.join(directory, 'pdfmarks.ps') with open(pdfmarks, 'w') as marks: if metadata: + mods = metadata.pop("MODS", "") + toc = metadata.pop("TOC", None) marks.write("[ ") for metakey, metaval in metadata.items(): if metaval: - marks.write(f"/{metakey} ({metaval})\n") - marks.write("/DOCINFO pdfmark\n") - # fixme: add XMP-embedded metadata: - # - DC (https://www.loc.gov/standards/mods/mods-dcsimple.html) - # - MODS-RDF (https://www.loc.gov/standards/mods/modsrdf/primer-2.html) + marks.write(f"/{metakey} {pdfmark_string(metaval)}\n") + marks.write("/DOCINFO pdfmark\n\n") + if mods: + # add XMP-embedded metadata + # TODO (maybe): convert to other formats: + # - DC (https://www.loc.gov/standards/mods/mods-dcsimple.html) + # - MODS-RDF (https://www.loc.gov/standards/mods/modsrdf/primer-2.html) + marks.write("[ /_objdef {modsMetadata}\n") + marks.write(" /type /stream /OBJ pdfmark\n") + marks.write("[ {modsMetadata} <<\n") + marks.write(" /Type /EmbeddedFile\n") + marks.write(" /Subtype (text/xml) cvn\n") + marks.write(" >> /PUT pdfmark\n") + marks.write("[ {modsMetadata} \n\n") + marks.write(pdfmark_string(mods)) + marks.write("\n\n /PUT pdfmark\n\n") + marks.write("[ {modsMetadata} /CLOSE pdfmark\n") + marks.write("[ {modsMetadata} << /Type /Metadata /Subtype /XML >> /PUT pdfmark\n") + marks.write("[{Catalog} {modsMetadata} /Metadata pdfmark\n") + if toc: + def struct2bookmark(struct): + subs = struct['subs'] + marks.write(f"[ /Title {pdfmark_string(struct['label'])}") + marks.write(f" /Page {struct['page'] or 0}") + if len(subs): + marks.write(f" /Count {len(struct['subs'])}") + marks.write(" /OUT pdfmark\n") + for sub in subs: + struct2bookmark(sub) + struct2bookmark(toc) if pagelabels: marks.write("[{Catalog} <<\n\ /PageLabels <<\n\ From 7c89da83b22a5107de9fba1d23a68d5873c75b1a Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 03:30:07 +0100 Subject: [PATCH 15/29] finalize processor docstrings --- ocrd_pagetopdf/alto_processor.py | 30 +++++++++++++++++------------- ocrd_pagetopdf/page_processor.py | 24 ++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/ocrd_pagetopdf/alto_processor.py b/ocrd_pagetopdf/alto_processor.py index 13d2750..df52d86 100644 --- a/ocrd_pagetopdf/alto_processor.py +++ b/ocrd_pagetopdf/alto_processor.py @@ -36,21 +36,25 @@ def setup(self): def process_page_file(self, *input_files: Optional[OcrdFileType]) -> None: """Converts all pages of the document to PDF - Find ALTO input files in the first fileGrp, - together with the image input files in the second fileGrp, - then first convert ALTO to PAGE in a temporary location. - Next, convert PAGE to PDF in a temporary location. - Copy to the output fileGrp on success and reference those - files in the METS. + For each page, find the ALTO input file in the first fileGrp, + together with the image input file in the second fileGrp. - If 'outlines',... - If 'textequiv_level'... - If 'negative2zero'... + Then convert ALTO to PAGE with PRImA PageConverter in a temporary location. - Finally, if 'multipage' is set, then concatenate all files to - a multi-page PDF file, setting 'pagelabels' accordingly. - Reference that file with 'multipage' as ID in the output fileGrp. - If 'multipage_only' is also set, then remove the single-page PDF files afterwards. + \b + Next convert the PAGE/image pair with PRImA PageToPdf in a temporary location, + applying + - ``textequiv_level`` (i.e. `-text-source`) to retrieve a text layer, if set; + - ``outlines`` to draw boundary polygons, if set; + - ``font`` accordingly; + - ``negative2zero`` (i.e. `-neg-coords toZero`) to repair negative coordintes. + + Copy to the resulting PDF file to the output file group and reference it in the METS. + + Finally, if ``multipage`` is set, then concatenate all generated files to + a multi-page PDF file, setting ``pagelabels`` accordingly, as well as PDF metadata + and bookmarks. Reference it with ``multipage`` as ID in the output fileGrp, too. + If ``multipage_only`` is also set, then remove the single-page PDF files afterwards. """ assert len(input_files) == 2 assert isinstance(input_files[0], get_args(OcrdFileType)) diff --git a/ocrd_pagetopdf/page_processor.py b/ocrd_pagetopdf/page_processor.py index c0fe47f..ea298af 100644 --- a/ocrd_pagetopdf/page_processor.py +++ b/ocrd_pagetopdf/page_processor.py @@ -114,8 +114,28 @@ def process_workspace(self, workspace: Workspace) -> None: def process_page_file(self, input_file: OcrdFileType) -> None: """Converts all pages of the document to PDF - Open and deserialize PAGE input files and their respective images, - then go to the page hierarchy level... FIXME + For each page, open and deserialize PAGE input file and its respective image. + Then extract a derived image of the (cropped, deskewed, binarized...) page, + with features depending on ``image_feature_selector`` (a comma-separated list + of required image features, cf. :py:func:`ocrd.workspace.Workspace.image_from_page`) + and ``image_feature_filter`` (a comma-separated list of forbidden image features). + + Next, generate a temporary PAGE output file for that very image (adapting all + coordinates if necessary). If ``negative2zero`` is set, validate and repair + invalid or inconsistent coordinates. + + \b + Convert the PAGE/image pair with PRImA PageToPdf, applying + - ``textequiv_level`` (i.e. `-text-source`) to retrieve a text layer, if set; + - ``outlines`` to draw boundary polygons, if set; + - ``font`` accordingly. + + Copy the resulting PDF file to the output file group and reference it in the METS. + + Finally, if ``multipage`` is set, then concatenate all generated files to + a multi-page PDF file, setting ``pagelabels`` accordingly, as well as PDF metadata + and bookmarks. Reference it with ``multipage`` as ID in the output file group, too. + If ``multipage_only`` is also set, then remove the single-page PDF files afterwards. """ assert isinstance(input_file, get_args(OcrdFileType)) page_id = input_file.pageId From 4cb800c642a5274d77ce5fb82fd048c8bce0fbe8 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 03:30:27 +0100 Subject: [PATCH 16/29] update makefile/dockerfile --- Dockerfile | 19 +++++++------- Makefile | 65 +++++++++++++++--------------------------------- requirements.txt | 2 +- 3 files changed, 31 insertions(+), 55 deletions(-) diff --git a/Dockerfile b/Dockerfile index 60c96af..1db3a1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM ocrd/core +ARG DOCKER_BASE_IMAGE +FROM $DOCKER_BASE_IMAGE ARG VCS_REF ARG BUILD_DATE @@ -9,18 +10,18 @@ LABEL \ org.label-schema.build-date=$BUILD_DATE ENV DEBIAN_FRONTEND noninteractive -ENV PREFIX=/usr/local -RUN apt-get update && apt-get install -y openjdk-8-jdk-headless wget git gcc unzip - -WORKDIR /build -COPY ptp ./ptp -COPY ocrd-pagetopdf . +WORKDIR /build/ocrd_pagetopdf +COPY setup.py . +COPY ocrd_pagetopdf ./ocrd_pagetopdf COPY ocrd-tool.json . +COPY requirements.txt . +COPY README.md . COPY Makefile . -RUN make install PREFIX=/usr/local SHELL="bash -x" +RUN make deps-ubuntu deps install \ + && rm -fr /build/ocrd_pagetopdf WORKDIR /data ENV DEBIAN_FRONTEND teletype -CMD ["/usr/local/bin/ocrd-pagetopdf", "--help"] +VOLUME ["/data"] diff --git a/Makefile b/Makefile index 5a0cce9..125e2ae 100644 --- a/Makefile +++ b/Makefile @@ -1,64 +1,37 @@ -PROJECT_NAME := ocrd_pagetopdf -SCRIPTS = ocrd-pagetopdf -DOCKER_TAG = ocrd/pagetopdf - -PIP ?= $(shell which pip) - -# Directory to install to ('$(PREFIX)') -PREFIX ?= $(if $(VIRTUAL_ENV),$(VIRTUAL_ENV),/usr/local) +PYTHON ?= python3 +PIP ?= pip3 -BINDIR = $(PREFIX)/bin -SHAREDIR = $(PREFIX)/share/$(PROJECT_NAME) - -# BEGIN-EVAL makefile-parser --make-help Makefile +DOCKER_BASE_IMAGE = docker.io/ocrd/core:v3.0.4 +DOCKER_TAG = ocrd/pagetopdf help: @echo "" @echo " Targets" @echo "" - @echo " deps-ubuntu Install system packages (on Debian/Ubuntu)" - @echo " deps Install python packages" - @echo " install Install the executable in $(PREFIX)/bin and the ocrd-tool.json to $(SHAREDIR)" - @echo " uninstall Uninstall scripts and $(SHAREDIR)" + @echo " deps-ubuntu Install system dependencies (on Debian/Ubuntu)" + @echo " deps Install Python dependencies via $(PIP)" + @echo " install Install the Python package via $(PIP)" + @echo " install-dev Install in editable mode" + @echo " build Build source and binary distribution" @echo " docker Build Docker image" - @echo "" - @echo " Variables" - @echo "" - @echo " PREFIX Directory to install to ('$(PREFIX)')" - -# END-EVAL # Install system packages (on Debian/Ubuntu) deps-ubuntu: - apt-get install -y python3 python3-venv default-jre-headless ghostscript + apt-get install -y python3 python3-venv default-jre-headless ghostscript git # Install python packages deps: - $(PIP) install ocrd # needed for ocrd CLI (and bashlib) + $(PIP) install -r requirements.txt -# Install the executable in $(PREFIX)/bin and the ocrd-tool.json to $(SHAREDIR) install: - mkdir -p $(BINDIR) - for script in $(SCRIPTS);do \ - sed 's,^SHAREDIR.*,SHAREDIR="$(SHAREDIR)",' $$script > $(BINDIR)/$$script ;\ - chmod a+x $(BINDIR)/$$script ;\ - done - mkdir -p $(SHAREDIR) - cp ocrd-tool.json $(SHAREDIR) - cp -r ptp $(SHAREDIR) -ifeq ($(findstring $(BINDIR),$(subst :, ,$(PATH))),) - @echo "you need to add '$(BINDIR)' to your PATH" -else - @echo "you already have '$(BINDIR)' in your PATH. good job." -endif + $(PIP) install . -# Uninstall scripts and $(SHAREDIR) -uninstall: - for script in $(SCRIPTS);do \ - rm --verbose --force "$(BINDIR)/$$script";\ - done - rm -rfv $(SHAREDIR) - make -C ocr-pagetopdf PREFIX=$(PREFIX) uninstall +install-dev: + $(PIP) install -e . + +build: + $(PIP) install build wheel + $(PYTHON) -m build . # Build Docker image docker: @@ -67,3 +40,5 @@ docker: --build-arg VCS_REF=$$(git rev-parse --short HEAD) \ --build-arg BUILD_DATE=$$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ -t $(DOCKER_TAG) . + +.PHONY: help deps-ubuntu deps install install-dev docker diff --git a/requirements.txt b/requirements.txt index 1841f1d..b667160 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -ocrd>=3.0 +ocrd>=3.0.4 scipy From 1918a0eee4e8847c6d124ed41bb74f8fe50969c5 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 14:24:08 +0100 Subject: [PATCH 17/29] multipage: do not string-format MODS XMP stream, but do avoid non-ASCII characters --- ocrd_pagetopdf/multipagepdf.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index cc64f86..da7c440 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -115,8 +115,8 @@ def get_metadata(mets): 'ModDate': iso8601toiso32000(modifieddate) if modifieddate else "", # not part of DOCINFO: 'Perms': access, - 'MODS': etree.tostring(mods, pretty_print=True, encoding="utf-8").decode("utf-8"), - 'TOC': get_structure(mets) + 'MODS': etree.tostring(mods, pretty_print=True).decode("ascii"), + 'TOC': get_structure(mets), } def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): @@ -190,9 +190,9 @@ def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, meta marks.write(" /Type /EmbeddedFile\n") marks.write(" /Subtype (text/xml) cvn\n") marks.write(" >> /PUT pdfmark\n") - marks.write("[ {modsMetadata} \n\n") - marks.write(pdfmark_string(mods)) - marks.write("\n\n /PUT pdfmark\n\n") + marks.write("[ {modsMetadata} (\n\n") + marks.write(mods) + marks.write("\n\n) /PUT pdfmark\n\n") marks.write("[ {modsMetadata} /CLOSE pdfmark\n") marks.write("[ {modsMetadata} << /Type /Metadata /Subtype /XML >> /PUT pdfmark\n") marks.write("[{Catalog} {modsMetadata} /Metadata pdfmark\n") From 4f6e2e337ac5085bcd7e8b20aeb112a45c5eb8a7 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 14:24:52 +0100 Subject: [PATCH 18/29] multipage: fix MODS author retrieval --- ocrd_pagetopdf/multipagepdf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index da7c440..57f9f35 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -87,9 +87,9 @@ def get_metadata(mets): [titleinfo.find('.//mods:partName', NS)]) if titlepart is not None) break - author = (mods.xpath('.//mods:name[mods:role/text()="aut"]' + author = (mods.xpath('.//mods:name[mods:role/mods:roleTerm[@type="code"]/text()="aut"]' '/mods:namePart[@type="family" or @type="given"]', namespaces=NS) + - mods.xpath('.//mods:name[mods:role/text()="cre"]' + mods.xpath('.//mods:name[mods:role/mods:roleTerm[@type="code"]/text()="cre"]' '/mods:namePart[@type="family" or @type="given"]', namespaces=NS)) author = next((part.text for part in author if part.attrib["type"] == "given"), "") \ @@ -177,7 +177,7 @@ def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, meta marks.write("[ ") for metakey, metaval in metadata.items(): if metaval: - marks.write(f"/{metakey} {pdfmark_string(metaval)}\n") + marks.write(f"/{metakey} {pdfmark_string(metaval.strip())}\n") marks.write("/DOCINFO pdfmark\n\n") if mods: # add XMP-embedded metadata From a1cba438d8b68dc100b5603bb901be27715e2a5e Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 14:25:14 +0100 Subject: [PATCH 19/29] multipage: make logical structMap parser more robust --- ocrd_pagetopdf/multipagepdf.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index 57f9f35..25513e6 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -39,13 +39,15 @@ def get_structure(metsroot): topdiv = topdiv.find('./mets:div', NS) #for div in innerdiv.iterdescendants('{%s}div' % NS['mets']): def find_depth(div, depth=0): + div_id = div.get('ID', div.getparent().get('ID')) return { - 'label': div.get('LABEL') or div.get('ORDERLABEL'), - 'type': div.get('TYPE'), - 'id': div.get('ID'), - 'page': pages.get(smlinks.get(div.get('ID'), ''), ''), + 'label': div.get('LABEL') or div.get('ORDERLABEL') or '', + 'type': div.get('TYPE') or '', + 'id': div_id, + 'page': pages.get(smlinks.get(div_id, ''), ''), 'depth': depth, - 'subs': [find_depth(subdiv, depth+1) for subdiv in div.findall('./mets:div', NS)] + 'subs': [find_depth(subdiv, depth+1) + for subdiv in div.findall('./mets:div', NS)] } struct = find_depth(innerdiv) return struct From 059103053b5e3ab1c5e1a3ef80f0195b74fcde1a Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 16:47:13 +0100 Subject: [PATCH 20/29] add some fonts as downloadable resources --- ocrd_pagetopdf/ocrd-tool.json | 94 ++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/ocrd_pagetopdf/ocrd-tool.json b/ocrd_pagetopdf/ocrd-tool.json index 6834b3e..064c3d2 100644 --- a/ocrd_pagetopdf/ocrd-tool.json +++ b/ocrd_pagetopdf/ocrd-tool.json @@ -96,7 +96,99 @@ "type": "string", "default": "" } - } + }, + "resources": [ + { + "url": "https://github.com/OCR-D/ocrd_vandalize/blob/main/ocrd_vandalize/UnifrakturMaguntia.ttf", + "name": "UnifrakturMaguntia.ttf", + "description": "Unicode-aware Fraktur font (based on Peter Wiegel's font Berthold Mainzer Fraktur which is in turn based on a 1901 typeface by Carl Albert Fahrenwaldt, cf. https://unifraktur.sourceforge.net/maguntia.html)", + "size": 257648 + }, + { + "url": "https://github.com/Layout-Parser/layout-parser/blob/main/src/layoutparser/misc/NotoSerifCJKjp-Regular.otf", + "name": "NotoSerifCJKjp-Regular.otf", + "description": "font with wide CJK support", + "size": 23607780 + }, + { + "url": "https://github.com/OCR4all/LAREX/blob/master/src/main/webapp/resources/fonts/AndronScriptorWeb.ttf", + "name": "AndronScriptorWeb.ttf", + "description": "Unicode MUFI Renaissance font (special edition of Andreas Stötzner’s Andron font project, issued to support scholarly editing purposes for medieval philological studies, cf. https://skaldic.org/m.php?p=doc&i=968)", + "size": 940752 + }, + { + "url": "https://sourceforge.net/projects/junicode/files/junicode/junicode-1.002/junicode-1.002.zip/download", + "name": "Junicode.ttf", + "type": "archive", + "path_in_archive": "Junicode.ttf", + "description": "Unicode MUFI font (short for Junius-Unicode) for medievalists with extensive coverage of the Latin Unicode ranges, plus Runic and Gothic (cf. https://junicode.sourceforge.io)", + "size": 1451694 + }, + { + "url": "https://sourceforge.net/projects/junicode/files/junicode/junicode-1.002/junicode-1.002.zip/download", + "name": "FoulisGreek.ttf", + "type": "archive", + "path_in_archive": "FoulisGreek.ttf", + "description": "Unicode MUFI font (short for Junius-Unicode) for medievalists with extensive coverage of the Greek Unicode ranges (cf. https://junicode.sourceforge.io)", + "size": 1451694 + }, + { + "url": "https://github.com/google/fonts/raw/refs/heads/main/apache/tinos/Tinos-Regular.ttf", + "name": "Tinos-Regular.ttf", + "description": "designed by Steve Matteson, serif design that is metrically compatible with Times New Roman. Tinos offers improved on-screen readability characteristics and the pan-European WGL character set (Cyrillic, Greek, Hebrew, Latin, Vietnamese) and solves the needs of developers looking for width-compatible fonts to address document portability across platforms (cf. https://github.com/googlefonts/tinos)", + "size": 475996 + }, + { + "url": "https://github.com/google/fonts/blob/refs/heads/main/apache/arimo/Arimo%5Bwght%5D.ttf", + "name": "Arimo.ttf", + "description": "designed by Steve Matteson, serif design that is metrically compatible with Arial. Arimo offers improved on-screen readability characteristics and the pan-European WGL character set (Cyrillic, Greek, Hebrew, Latin, Vietnamese) and solves the needs of developers looking for width-compatible fonts to address document portability across platforms (cf. https://github.com/googlefonts/arimo)", + "size": 231983 + }, + { + "url": "https://www.ligafaktur.de/LUC.UnicodeFrakturU1A.otf", + "name": "LUC.UnicodeFrakturU1A.otf", + "description": "Unicode Fraktur, fully manual Fraktursatz (all ligatures available via MUFI/UNZ1 codepoint), by Ulrich Zeidler (cf. https://www.ligafaktur.de)", + "size": 146080 + }, + { + "url": "https://www.ligafaktur.de/LOB.UnicodeFraktur.otf", + "name": "LOB.UnicodeFraktur.otf", + "description": "Unicode Fraktur, partly automatic Fraktursatz (all ligatures selected automatically unless suppressed via zero-width non-joiner, ſ only via extra codepoint), by Ulrich Zeidler (cf. https://www.ligafaktur.de)", + "size": 150520 + }, + { + "url": "https://www.ligafaktur.de/LOV.UnicodeFraktur.otf", + "name": "LOV.UnicodeFraktur.otf", + "description": "Unicode Fraktur, fully automatic Fraktursatz (all ligatures selected automatically unless suppressed via zero-width non-joiner, ſ selected automatically), by Ulrich Zeidler (cf. https://www.ligafaktur.de)", + "size": 221224 + }, + { + "url": "https://www.ligafaktur.de/LUC.NeueDeutscheKurrent.zip", + "name": "LUC.NeueDeutscheKurrentU1T.otf", + "type": "archive", + "path_in_archive": "LUC.NeueDeutscheKurrentU1T#.otf", + "description": "Kurrent German cursive, fully manual Fraktursatz (all ligatures available via MUFI/UNZ1 codepoint), by Ulrich Zeidler (cf. https://www.ligafaktur.de)", + "size": 27863 + }, + { + "url": "https://www.ligafaktur.de/LOB.NeueDeutscheKurrent.otf", + "name": "LOB.NeueDeutscheKurrent.otf", + "description": "Kurrent German cursive, partly automatic Fraktursatz (all ligatures selected automatically unless suppressed via zero-width non-joiner, ſ only via extra codepoint), by Ulrich Zeidler (cf. https://www.ligafaktur.de)", + "size": 47696 + }, + { + "url": "https://www.ligafaktur.de/LOV.NeueDeutscheKurrent.otf", + "name": "LOV.NeueDeutscheKurrent.otf", + "description": "Kurrent German cursive, fully automatic Fraktursatz (all ligatures selected automatically unless suppressed via zero-width non-joiner, ſ selected automatically), by Ulrich Zeidler (cf. https://www.ligafaktur.de)", + "size": 105184 + }, + { + "url": "https://www.zinken.net/Fonts/DeutscheKurrent.ttf", + "name": "DeutscheKurrent.ttf", + "description": "Kurrent German cursive, automatic Fraktursatz (ligatures and ſ solely via OpenType features, no extra codepoints), by Hans Zinken (cf. https://www.zinken.net/Fonts/Kurrent.html)", + "size": 80032 + } + ] }, "ocrd-altotopdf": { "executable": "ocrd-altotopdf", From d04fb69e6812c736ced4d1fb9d3d2805e273678a Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 19:51:14 +0100 Subject: [PATCH 21/29] refactor to avoid get_physical_pages on ClientSideOcrdMets --- ocrd_pagetopdf/multipagepdf.py | 49 ++++++++++++++++++-------------- ocrd_pagetopdf/page_processor.py | 24 +++++++++------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/ocrd_pagetopdf/multipagepdf.py b/ocrd_pagetopdf/multipagepdf.py index 25513e6..d55c310 100644 --- a/ocrd_pagetopdf/multipagepdf.py +++ b/ocrd_pagetopdf/multipagepdf.py @@ -2,7 +2,7 @@ from importlib.metadata import version from datetime import datetime -from typing import Dict, List, Optional +from typing import Dict, List, Union, Optional import os.path from tempfile import TemporaryDirectory from logging import getLogger @@ -12,7 +12,8 @@ from lxml import etree from ocrd_models.constants import NAMESPACES as NS -def get_structure(metsroot): +def get_structure(mets): + metsroot = mets._tree.getroot() try: structlink = next(metsroot.iterfind('.//mets:structLink', NS)) smlinks = {link.get('{http://www.w3.org/1999/xlink}from'): @@ -118,15 +119,12 @@ def get_metadata(mets): # not part of DOCINFO: 'Perms': access, 'MODS': etree.tostring(mods, pretty_print=True).decode("ascii"), - 'TOC': get_structure(mets), } -def read_from_mets(mets, filegrp, page_ids, pagelabel='pageId'): +def read_from_mets(mets, filegrp, page_ids, pages, pagelabel='pageId'): file_names = [] pagelabels = [] file_ids = [] - if pagelabel == "pagelabel": - pages = mets.get_physical_pages(for_pageIds=page_ids, return_divs=True) for f in mets.find_files(mimetype='application/pdf', fileGrp=filegrp, pageId=page_ids or None): # ignore existing multipage PDFs if f.pageId: @@ -170,12 +168,15 @@ def pdfmark_string(string): return '<{}>'.format(''.join('{:02X}'.format(byte) for byte in bstring)) -def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None) -> str: +def create_pdfmarks(directory: str, + metadata: Dict[str,str] = None, + pagelabels: Optional[List[str]] = None, + structure: Optional[Dict[str,Union[str,list]]] = None +) -> str: pdfmarks = os.path.join(directory, 'pdfmarks.ps') with open(pdfmarks, 'w') as marks: if metadata: mods = metadata.pop("MODS", "") - toc = metadata.pop("TOC", None) marks.write("[ ") for metakey, metaval in metadata.items(): if metaval: @@ -198,17 +199,17 @@ def create_pdfmarks(directory: str, pagelabels: Optional[List[str]] = None, meta marks.write("[ {modsMetadata} /CLOSE pdfmark\n") marks.write("[ {modsMetadata} << /Type /Metadata /Subtype /XML >> /PUT pdfmark\n") marks.write("[{Catalog} {modsMetadata} /Metadata pdfmark\n") - if toc: - def struct2bookmark(struct): - subs = struct['subs'] - marks.write(f"[ /Title {pdfmark_string(struct['label'])}") - marks.write(f" /Page {struct['page'] or 0}") - if len(subs): - marks.write(f" /Count {len(struct['subs'])}") - marks.write(" /OUT pdfmark\n") - for sub in subs: - struct2bookmark(sub) - struct2bookmark(toc) + if structure: + def struct2bookmark(struct): + subs = struct['subs'] + marks.write(f"[ /Title {pdfmark_string(struct['label'])}") + marks.write(f" /Page {struct['page'] or 0}") + if len(subs): + marks.write(f" /Count {len(struct['subs'])}") + marks.write(" /OUT pdfmark\n") + for sub in subs: + struct2bookmark(sub) + struct2bookmark(structure) if pagelabels: marks.write("[{Catalog} <<\n\ /PageLabels <<\n\ @@ -219,12 +220,18 @@ def struct2bookmark(struct): marks.write("] >> >> /PUT pdfmark") return pdfmarks -def pdfmerge(inputfiles: List[str], outputfile: str, pagelabels: Optional[List[str]] = None, metadata: Dict[str,str] = None, log=None) -> None: +def pdfmerge(inputfiles: List[str], + outputfile: str, + metadata: Dict[str,str] = None, + pagelabels: Optional[List[str]] = None, + structure: Optional[Dict[str,Union[str,list]]] = None, + log=None, +) -> None: if log is None: log = getLogger('ocrd.processor.pagetopdf') inputfiles = ' '.join(inputfiles) with TemporaryDirectory() as tmpdir: - pdfmarks = create_pdfmarks(tmpdir, pagelabels, metadata) + pdfmarks = create_pdfmarks(tmpdir, metadata, pagelabels, structure) result = subprocess.run( "gs -q -sDEVICE=pdfwrite -dNOPAUSE -dBATCH -dSAFER " f"-sOutputFile={outputfile} {inputfiles} {pdfmarks}", diff --git a/ocrd_pagetopdf/page_processor.py b/ocrd_pagetopdf/page_processor.py index ea298af..fd9fb9d 100644 --- a/ocrd_pagetopdf/page_processor.py +++ b/ocrd_pagetopdf/page_processor.py @@ -79,24 +79,28 @@ def process_workspace(self, workspace: Workspace) -> None: if not output_file_path.lower().endswith('.pdf'): output_file_path += '.pdf' self.logger.info("aggregating multi-page PDF to %s", output_file_path) - pdffiles, pagelabels, pdffile_ids = multipagepdf.read_from_mets( - workspace.mets, self.output_file_grp, self.page_id, - pagelabel=self.parameter['pagelabel'] - ) - if not pdffiles: - self.logger.warning("No single-page files, skipping multi-page output '%s'", output_file_path) - return if isinstance(workspace.mets, ClientSideOcrdMets): # we cannot use METS Server for MODS queries # instantiate (read and parse) METS from disk (read-only, metadata are constant) ws = Workspace(workspace.resolver, workspace.directory, mets_basename=os.path.basename(workspace.mets_target)) - metadata = multipagepdf.get_metadata(ws.mets) else: - metadata = multipagepdf.get_metadata(workspace.mets) + ws = workspace + pages = ws.mets.get_physical_pages(for_pageIds=self.page_id, return_divs=True) + pdffiles, pagelabels, pdffile_ids = multipagepdf.read_from_mets( + workspace.mets, self.output_file_grp, self.page_id, pages, + pagelabel=self.parameter['pagelabel'] + ) + if not pdffiles: + self.logger.warning("No single-page files, skipping multi-page output '%s'", output_file_path) + return + metadata = multipagepdf.get_metadata(ws.mets) + structure = multipagepdf.get_structure(ws.mets) multipagepdf.pdfmerge( pdffiles, output_file_path, - pagelabels=pagelabels, metadata=metadata, + metadata=metadata, + pagelabels=pagelabels, + structure=structure, log=self.logger) workspace.add_file( file_id=output_file_id, From dfaa24969443c8106224ac4df10a019c9a0faaff Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Fri, 21 Feb 2025 19:52:36 +0100 Subject: [PATCH 22/29] add basic tests --- .gitignore | 3 ++ .gitmodules | 3 ++ Makefile | 22 ++++++++++ repo/assets | 1 + tests/__init__.py | 0 tests/conftest.py | 90 +++++++++++++++++++++++++++++++++++++++++ tests/test_pagetopdf.py | 36 +++++++++++++++++ 7 files changed, 155 insertions(+) create mode 100644 .gitmodules create mode 160000 repo/assets create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_pagetopdf.py diff --git a/.gitignore b/.gitignore index 4267c2e..2692103 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ coverage.xml # emacs bkup *~ + +# temporary clone of assets +tests/assets/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5b24fbb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "repo/assets"] + path = repo/assets + url = https://github.com/OCR-D/assets diff --git a/Makefile b/Makefile index 125e2ae..65e0c5d 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,28 @@ build: $(PIP) install build wheel $(PYTHON) -m build . +# Run test +test: tests/assets + $(PYTHON) -m pytest tests --durations=0 $(PYTEST_ARGS) + +# +# Assets +# + +# Update OCR-D/assets submodule +.PHONY: repos always-update tests/assets +repo/assets: always-update + git submodule sync --recursive $@ + if git submodule status --recursive $@ | grep -qv '^ '; then \ + git submodule update --init --recursive $@ && \ + touch $@; \ + fi + +# Setup test assets +tests/assets: repo/assets + mkdir -p tests/assets + cp -a repo/assets/data/* tests/assets + # Build Docker image docker: docker build \ diff --git a/repo/assets b/repo/assets new file mode 160000 index 0000000..d004ab7 --- /dev/null +++ b/repo/assets @@ -0,0 +1 @@ +Subproject commit d004ab7211fb3d57801e783b184a7ded1e2f5e4b diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..05b923c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,90 @@ +# pylint: disable=unused-import + +from multiprocessing import Process +from time import sleep +import pytest + +from ocrd import Resolver, Workspace, OcrdMetsServer +from ocrd_utils import pushd_popd, disableLogging, initLogging, setOverrideLogLevel, config + +from .assets import assets + +CONFIGS = ['', 'pageparallel', 'metscache', 'pageparallel+metscache'] + +@pytest.fixture(params=CONFIGS) +def workspace(tmpdir, pytestconfig, request): + def _make_workspace(workspace_path): + initLogging() + if pytestconfig.getoption('verbose') > 0: + setOverrideLogLevel('DEBUG') + with pushd_popd(tmpdir): + directory = str(tmpdir) + resolver = Resolver() + workspace = resolver.workspace_from_url(workspace_path, dst_dir=directory, download=True) + config.OCRD_MISSING_OUTPUT = "ABORT" + if 'metscache' in request.param: + config.OCRD_METS_CACHING = True + print("enabled METS caching") + if 'pageparallel' in request.param: + config.OCRD_MAX_PARALLEL_PAGES = 4 + print("enabled page-parallel processing") + def _start_mets_server(*args, **kwargs): + print("running with METS server") + server = OcrdMetsServer(*args, **kwargs) + server.startup() + process = Process(target=_start_mets_server, + kwargs={'workspace': workspace, 'url': 'mets.sock'}) + process.start() + sleep(1) + workspace = Workspace(resolver, directory, mets_server_url='mets.sock') + yield {'workspace': workspace, 'mets_server_url': 'mets.sock'} + process.terminate() + else: + yield {'workspace': workspace} + config.reset_defaults() + disableLogging() + return _make_workspace + + +@pytest.fixture +def workspace_manifesto(workspace): + yield from workspace(assets.path_to('communist_manifesto/data/mets.xml')) + +@pytest.fixture +def workspace_aufklaerung(workspace): + yield from workspace(assets.path_to('kant_aufklaerung_1784/data/mets.xml')) + +@pytest.fixture +def workspace_aufklaerung_region(workspace): + yield from workspace(assets.path_to('kant_aufklaerung_1784-page-region/data/mets.xml')) + +@pytest.fixture +def workspace_sbb(workspace): + yield from workspace(assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')) + +@pytest.fixture +def workspace_wiegendrucke(workspace): + # http://digital.slub-dresden.de/id312439970 + yield from workspace("https://digital.slub-dresden.de/oai/?verb=GetRecord&metadataPrefix=mets&identifier=oai:de:slub-dresden:db:id-312439970") + +@pytest.fixture +def workspace_mundkoch(workspace): + # http://digital.slub-dresden.de/id1832552268 + yield from workspace("https://digital.slub-dresden.de/oai/?verb=GetRecord&metadataPrefix=mets&identifier=oai:de:slub-dresden:db:id-1832552268") + +@pytest.fixture +def workspace_boersenblatt(workspace): + # http://digital.slub-dresden.de/id39946221X-18440511 + yield from workspace("https://digital.slub-dresden.de/oai/?verb=GetRecord&metadataPrefix=mets&identifier=oai:de:slub-dresden:db:id-39946221X-18440511") + +@pytest.fixture +def workspace_homercomment(workspace): + # http://dx.doi.org/10.25673/82290 + yield from workspace("https://opendata.uni-halle.de/explore?bitstream_id=1854f8cb-6561-4faf-bee3-a3d4c65ff2fd&handle=1981185920/84245&provider=dfg-viewer&onlyManifest=true") + +@pytest.fixture +def workspace_pembroke(workspace): + # http://resolver.staatsbibliothek-berlin.de/SBB0001CA7900000000 + yield from workspace("https://content.staatsbibliothek-berlin.de/dc/PPN85249078X.mets.xml") + + diff --git a/tests/test_pagetopdf.py b/tests/test_pagetopdf.py new file mode 100644 index 0000000..de41ada --- /dev/null +++ b/tests/test_pagetopdf.py @@ -0,0 +1,36 @@ +# pylint: disable=import-error + +import os + +from ocrd import run_processor +from ocrd_utils import MIMETYPE_PAGE +from ocrd_models.constants import NAMESPACES + +from ocrd_pagetopdf.page_processor import PAGE2PDF + +PARAM = { + "pagelabel": "pagelabel", + "multipage": "FULLDOWNLOAD", + "textequiv_level": "line", + "outlines": "region", + "negative2zero": True, + "image_feature_filter": "binarized", +} + +def test_convert(workspace_aufklaerung): + run_processor(PAGE2PDF, + input_file_grp="OCR-D-GT-PAGE", + output_file_grp="OCR-D-GT-PDF", + parameter=PARAM, + **workspace_aufklaerung, + ) + ws = workspace_aufklaerung['workspace'] + ws.save_mets() + assert os.path.isdir(os.path.join(ws.directory, 'OCR-D-GT-PDF')) + results = ws.find_files(file_grp='OCR-D-GT-PDF', mimetype="application/pdf") + result0 = next(results, False) + assert result0, "found no output PDF files" + assert len(list(results)) > 1 + results = ws.find_files(file_grp='OCR-D-GT-PDF', file_id="FULLDOWNLOAD", mimetype="application/pdf") + result0 = next(results, False) + assert result0, "found no output multi-page PDF file" From 181f9b34589da75f23f4165bbdd40f01a7d99566 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Sat, 22 Feb 2025 04:01:04 +0100 Subject: [PATCH 23/29] altotopdf: improve logging --- ocrd_pagetopdf/alto_processor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ocrd_pagetopdf/alto_processor.py b/ocrd_pagetopdf/alto_processor.py index df52d86..57b026a 100644 --- a/ocrd_pagetopdf/alto_processor.py +++ b/ocrd_pagetopdf/alto_processor.py @@ -62,7 +62,9 @@ def process_page_file(self, *input_files: Optional[OcrdFileType]) -> None: assert input_files[0].mimetype in ['application/alto+xml', 'text/xml'] assert input_files[1].mimetype.startswith('image/') alto_file = input_files[0] + assert os.path.exists(alto_file.local_filename) image_file = input_files[1] + assert os.path.exists(image_file.local_filename) page_id = alto_file.pageId self._base_logger.info("processing page %s", page_id) output_file_id = make_file_id(alto_file, self.output_file_grp) @@ -80,13 +82,21 @@ def process_page_file(self, *input_files: Optional[OcrdFileType]) -> None: copyfile(alto_file.local_filename, alto_path) page_path = os.path.join(tmpdir, "page.xml") converter2 = ' '.join(self.cliparams2 + ["-source-xml", alto_path, "-target-xml", page_path]) + self.logger.debug("Running command: '%s'", converter2) result = subprocess.run(converter2, shell=True, text=True, capture_output=True, # does not show stdout and stderr: #check=True, encoding="utf-8") + # logging commented as long as prima-page-converter#19 is not fixed + # if result.stdout: + # self.logger.debug("PageConverter for %s stdout: %s", page_id, result.stdout) + # if result.stderr: + # self.logger.warning("PageConverter for %s stderr: %s", page_id, result.stderr) if result.returncode != 0: + self.logger.warning("PageConverter for %s stderr: %s", page_id, result.stderr) raise Exception("PageConverter command failed", result) if not os.path.exists(page_path) or not os.path.getsize(page_path): + self.logger.warning("PageConverter for %s stderr: %s", page_id, result.stderr) raise Exception("PageConverter result is empty", result) img_path = os.path.join(tmpdir, "image.png") copyfile(image_file.local_filename, img_path) From e649b8fd619364217388b5d310c74d2849fa9dd2 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Sat, 22 Feb 2025 04:01:39 +0100 Subject: [PATCH 24/29] tests: work around core#1149 by downloading remotely --- Makefile | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Makefile b/Makefile index 65e0c5d..166b1fd 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,15 @@ help: @echo " install-dev Install in editable mode" @echo " build Build source and binary distribution" @echo " docker Build Docker image" + @echo " test Run tests via Pytest" + @echo " repo/assets Clone OCR-D/assets to ./repo/assets" + @echo " tests/assets Setup test assets" + @echo "" + @echo " Variables" + @echo "" + @echo " DOCKER_TAG Docker container tag ($(DOCKER_TAG))" + @echo " PYTEST_ARGS Additional runtime options for pytest ($(PYTEST_ARGS))" + @echo " (See --help, esp. custom option --workspace)" # Install system packages (on Debian/Ubuntu) deps-ubuntu: @@ -33,6 +42,8 @@ build: $(PIP) install build wheel $(PYTHON) -m build . +# TODO: once core#1149 is fixed, remove this line (so the local copy can be used) +test: export OCRD_BASEURL=https://github.com/OCR-D/assets/raw/refs/heads/master/data/ # Run test test: tests/assets $(PYTHON) -m pytest tests --durations=0 $(PYTEST_ARGS) From 5a0c3cec7c4790dd3248165babc04d40ddee799f Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Sat, 22 Feb 2025 04:05:24 +0100 Subject: [PATCH 25/29] tests: add some METS URLs, test all config / workspace combinations, add pytest option --workspace for subsets, determine input fileGrp automatically, download and process up to 4 random pages only, test PAGE2PDF and ALTO2PDF, depending on whether PAGE or ALTO is in the workspace --- tests/conftest.py | 178 +++++++++++++++++++++++----------------- tests/test_pagetopdf.py | 61 ++++++++++---- 2 files changed, 149 insertions(+), 90 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 05b923c..29258b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ from multiprocessing import Process from time import sleep +from random import seed, sample +import os import pytest from ocrd import Resolver, Workspace, OcrdMetsServer @@ -9,82 +11,106 @@ from .assets import assets -CONFIGS = ['', 'pageparallel', 'metscache', 'pageparallel+metscache'] - -@pytest.fixture(params=CONFIGS) -def workspace(tmpdir, pytestconfig, request): - def _make_workspace(workspace_path): - initLogging() - if pytestconfig.getoption('verbose') > 0: - setOverrideLogLevel('DEBUG') - with pushd_popd(tmpdir): - directory = str(tmpdir) - resolver = Resolver() - workspace = resolver.workspace_from_url(workspace_path, dst_dir=directory, download=True) - config.OCRD_MISSING_OUTPUT = "ABORT" - if 'metscache' in request.param: - config.OCRD_METS_CACHING = True - print("enabled METS caching") - if 'pageparallel' in request.param: - config.OCRD_MAX_PARALLEL_PAGES = 4 - print("enabled page-parallel processing") - def _start_mets_server(*args, **kwargs): - print("running with METS server") - server = OcrdMetsServer(*args, **kwargs) - server.startup() - process = Process(target=_start_mets_server, - kwargs={'workspace': workspace, 'url': 'mets.sock'}) - process.start() - sleep(1) - workspace = Workspace(resolver, directory, mets_server_url='mets.sock') - yield {'workspace': workspace, 'mets_server_url': 'mets.sock'} - process.terminate() - else: - yield {'workspace': workspace} - config.reset_defaults() - disableLogging() - return _make_workspace - - +WORKSPACES = { + "manifesto": assets.path_to('communist_manifesto/data/mets.xml'), + "aufklaerung": assets.path_to('kant_aufklaerung_1784/data/mets.xml'), + "sbb": assets.url_of('SBB0000F29300010000/data/mets.xml'), + "wiegendrucke": # http://digital.slub-dresden.de/id312439970 + "https://digital.slub-dresden.de/oai/" + "?verb=GetRecord&metadataPrefix=mets" + "&identifier=oai:de:slub-dresden:db:id-312439970", + "mundkoch": # http://digital.slub-dresden.de/id1832552268 + "https://digital.slub-dresden.de/oai/" + "?verb=GetRecord&metadataPrefix=mets" + "&identifier=oai:de:slub-dresden:db:id-1832552268", + "boersenblatt": # http://digital.slub-dresden.de/id39946221X-18440511 + "https://digital.slub-dresden.de/oai/" + "?verb=GetRecord&metadataPrefix=mets" + "&identifier=oai:de:slub-dresden:db:id-39946221X-18440511", + "homercomment": # http://dx.doi.org/10.25673/82290 + "https://opendata.uni-halle.de/explore" + "?bitstream_id=1854f8cb-6561-4faf-bee3-a3d4c65ff2fd" + "&handle=1981185920/84245" + "&provider=dfg-viewer&onlyManifest=true", + "pembroke": # http://resolver.staatsbibliothek-berlin.de/SBB0001CA7900000000 + "https://content.staatsbibliothek-berlin.de" + "/dc/PPN85249078X.mets.xml", +} + +#@pytest.fixture(params=WORKSPACES.keys()) @pytest.fixture -def workspace_manifesto(workspace): - yield from workspace(assets.path_to('communist_manifesto/data/mets.xml')) - -@pytest.fixture -def workspace_aufklaerung(workspace): - yield from workspace(assets.path_to('kant_aufklaerung_1784/data/mets.xml')) - -@pytest.fixture -def workspace_aufklaerung_region(workspace): - yield from workspace(assets.path_to('kant_aufklaerung_1784-page-region/data/mets.xml')) - -@pytest.fixture -def workspace_sbb(workspace): - yield from workspace(assets.url_of('SBB0000F29300010000/data/mets_one_file.xml')) - -@pytest.fixture -def workspace_wiegendrucke(workspace): - # http://digital.slub-dresden.de/id312439970 - yield from workspace("https://digital.slub-dresden.de/oai/?verb=GetRecord&metadataPrefix=mets&identifier=oai:de:slub-dresden:db:id-312439970") - -@pytest.fixture -def workspace_mundkoch(workspace): - # http://digital.slub-dresden.de/id1832552268 - yield from workspace("https://digital.slub-dresden.de/oai/?verb=GetRecord&metadataPrefix=mets&identifier=oai:de:slub-dresden:db:id-1832552268") - -@pytest.fixture -def workspace_boersenblatt(workspace): - # http://digital.slub-dresden.de/id39946221X-18440511 - yield from workspace("https://digital.slub-dresden.de/oai/?verb=GetRecord&metadataPrefix=mets&identifier=oai:de:slub-dresden:db:id-39946221X-18440511") - -@pytest.fixture -def workspace_homercomment(workspace): - # http://dx.doi.org/10.25673/82290 - yield from workspace("https://opendata.uni-halle.de/explore?bitstream_id=1854f8cb-6561-4faf-bee3-a3d4c65ff2fd&handle=1981185920/84245&provider=dfg-viewer&onlyManifest=true") - -@pytest.fixture -def workspace_pembroke(workspace): - # http://resolver.staatsbibliothek-berlin.de/SBB0001CA7900000000 - yield from workspace("https://content.staatsbibliothek-berlin.de/dc/PPN85249078X.mets.xml") +def workspace(tmpdir, pytestconfig, asset): + initLogging() + if pytestconfig.getoption('verbose') > 0: + setOverrideLogLevel('DEBUG') + with pushd_popd(tmpdir): + directory = str(tmpdir) + resolver = Resolver() + url = WORKSPACES[asset] + workspace = resolver.workspace_from_url(url, dst_dir=directory) # download=True + workspace.name = asset # for debugging + # download only up to 4 pages + pages = workspace.mets.physical_pages + if len(pages) > 4: + seed(12) # make tests repeatable + pages = sample(pages, 4) + page_id = ','.join(pages) + for file in workspace.find_files(page_id=page_id): + if file.url.startswith("file:/") or file.fileGrp in ["THUMBS", "MIN"]: + # ignore broken and irrelevant groups + # (first image group will be used for alto_processor tests) + workspace.remove_file(file.ID, force=True) + else: + workspace.download_file(file) + yield workspace, page_id + disableLogging() + +def pytest_addoption(parser): + parser.addoption("--workspace", + action="append", + choices=list(WORKSPACES) + ["all"], + help="workspace(s) to run on (set 'all' to use all)" + ) + +@pytest.hookimpl +def pytest_generate_tests(metafunc): + if "asset" in metafunc.fixturenames: + ws = metafunc.config.getoption("workspace") + if ws == ['all']: + ws = list(WORKSPACES) + elif not ws: + ws = ["aufklaerung"] # default + metafunc.parametrize("asset", ws) +CONFIGS = ['', 'pageparallel', 'metscache', 'pageparallel+metscache'] +@pytest.fixture(params=CONFIGS) +def processor_kwargs(request, workspace): + config.OCRD_DOWNLOAD_INPUT = False # only 4 pre-downloaded pages + workspace, page_id = workspace + config.OCRD_MISSING_OUTPUT = "ABORT" + if 'metscache' in request.param: + config.OCRD_METS_CACHING = True + #print("enabled METS caching") + if 'pageparallel' in request.param: + config.OCRD_MAX_PARALLEL_PAGES = 4 + #print("enabled page-parallel processing") + def _start_mets_server(*args, **kwargs): + #print("running with METS server") + server = OcrdMetsServer(*args, **kwargs) + server.startup() + process = Process(target=_start_mets_server, + kwargs={'workspace': workspace, 'url': 'mets.sock'}) + process.start() + sleep(1) + # instantiate client-side workspace + asset = workspace.name + workspace = Workspace(workspace.resolver, workspace.directory, + mets_server_url='mets.sock', + mets_basename=os.path.basename(workspace.mets_target)) + workspace.name = asset + yield {'workspace': workspace, 'page_id': page_id, 'mets_server_url': 'mets.sock'} + process.terminate() + else: + yield {'workspace': workspace, 'page_id': page_id} + config.reset_defaults() diff --git a/tests/test_pagetopdf.py b/tests/test_pagetopdf.py index de41ada..0093645 100644 --- a/tests/test_pagetopdf.py +++ b/tests/test_pagetopdf.py @@ -1,36 +1,69 @@ # pylint: disable=import-error import os +import pytest from ocrd import run_processor from ocrd_utils import MIMETYPE_PAGE from ocrd_models.constants import NAMESPACES from ocrd_pagetopdf.page_processor import PAGE2PDF +from ocrd_pagetopdf.alto_processor import ALTO2PDF -PARAM = { +ALTO_PARAM = { "pagelabel": "pagelabel", "multipage": "FULLDOWNLOAD", "textequiv_level": "line", "outlines": "region", "negative2zero": True, +} +PAGE_PARAM = { "image_feature_filter": "binarized", + **ALTO_PARAM } +MIMETYPE_ALTO = '//text/xml|application/alto[+]xml' -def test_convert(workspace_aufklaerung): - run_processor(PAGE2PDF, - input_file_grp="OCR-D-GT-PAGE", - output_file_grp="OCR-D-GT-PDF", - parameter=PARAM, - **workspace_aufklaerung, +def test_convert(processor_kwargs): + ws = processor_kwargs['workspace'] + pages = processor_kwargs['page_id'].split(',') + page1 = pages[0] + # find last PAGE grp + file1 = next(reversed(list(ws.find_files(page_id=page1, mimetype=MIMETYPE_PAGE))), None) + if file1 is None: + # find last ALTO grp + file1 = next(reversed(list(ws.find_files(page_id=page1, mimetype=MIMETYPE_ALTO))), None) + if file1 is None: + pytest.skip(f"workspace asset {ws.name} has neither PAGE nor ALTO files") + else: + print(f"workspace {ws.name} first ALTO fileGrp is {file1.fileGrp}") + # find last image grp + file2 = next(reversed(list(ws.find_files(page_id=page1, mimetype="//image/.*"))), None) + if file2 is None: + pytest.skip(f"workspace asset {ws.name} has ALTO but no image files") + print(f"workspace {ws.name} first image fileGrp is {file2.fileGrp}") + processor_class = ALTO2PDF + processor_param = ALTO_PARAM + input_file_grp = file1.fileGrp + "," + file2.fileGrp + output_file_grp = file1.fileGrp + "-PDF" + else: + print(f"workspace {ws.name} first PAGE fileGrp is {file1.fileGrp}") + processor_class = PAGE2PDF + processor_param = PAGE_PARAM + input_file_grp = file1.fileGrp + output_file_grp = input_file_grp + "-PDF" + run_processor(processor_class, + input_file_grp=input_file_grp, + output_file_grp=output_file_grp, + parameter=processor_param, + **processor_kwargs, ) - ws = workspace_aufklaerung['workspace'] ws.save_mets() - assert os.path.isdir(os.path.join(ws.directory, 'OCR-D-GT-PDF')) - results = ws.find_files(file_grp='OCR-D-GT-PDF', mimetype="application/pdf") - result0 = next(results, False) - assert result0, "found no output PDF files" - assert len(list(results)) > 1 - results = ws.find_files(file_grp='OCR-D-GT-PDF', file_id="FULLDOWNLOAD", mimetype="application/pdf") + assert os.path.isdir(os.path.join(ws.directory, output_file_grp)) + results = [file.pageId for file in ws.find_files(file_grp=output_file_grp, mimetype="application/pdf")] + assert len(results), "found no output PDF files" + if ws.name == 'sbb': + pages.remove('PHYS_0005') # not in all fileGrps + assert len(results) > len(pages) + results = ws.find_files(file_grp=output_file_grp, file_id="FULLDOWNLOAD", mimetype="application/pdf") result0 = next(results, False) assert result0, "found no output multi-page PDF file" From df5bfa4663d4d7a5ac96d3ec089e445733309fc2 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 4 Mar 2025 16:27:45 +0100 Subject: [PATCH 26/29] update readme, add CI+CD --- .github/workflows/ci.yml | 47 +++++ .github/workflows/docker.yml | 47 +++++ .github/workflows/pypi.yml | 29 +++ README.md | 388 +++++++++++++++++++++++++++++++++-- 4 files changed, 492 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/pypi.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a4fa1a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +# Continuous integration for ocrd_pagetopdf + +name: Python CI + +on: + push: + branches: [ "master" ] + pull_request: + workflow_dispatch: + inputs: + upterm-session: + description: 'Run SSH login server for debugging' + default: False + type: boolean + +jobs: + ci_test: + name: CI build and test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Setup upterm session + # interactive SSH logins for debugging + if: github.event.inputs.upterm-session == 'true' + uses: lhotari/action-upterm@v1 + - name: Install dependencies + run: | + sudo make deps-ubuntu + make deps + - name: Install package + run: | + python3 --version + make install + pip check + - name: Run tests + run: | + pip install pytest + make test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..aa629f7 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,47 @@ +name: Dockerhub CD + +on: + push: + branches: [ "master" ] + workflow_dispatch: + inputs: + docker-tagname: + description: Tag name of the Docker image + default: 'ocrd/pagetopdf' + +env: + DOCKER_TAGNAME: ${{ github.evenv.inputs.docker-tagname || 'ocrd/pagetopdf' }} + +jobs: + + build: + + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - uses: actions/checkout@v4 + - # Activate cache export feature to reduce build time of image + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Build the Docker image + run: make docker DOCKER_TAG=${{ env.DOCKER_TAGNAME }} + - name: Login to Dockerhub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Push image to Dockerhub + run: docker push ${{ env.DOCKER_TAGNAME }} + - name: Alias the Docker image for GHCR + run: docker tag ${{ env.DOCKER_TAGNAME }} ghcr.io/${{ github.repository }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push image to Github Container Registry + run: docker push ghcr.io/${{ github.repository }} diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..1b239c0 --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,29 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: PyPI CD + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel build twine + pip install -r requirements.txt + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: twine upload --verbose dist/ocrd*${{ github.ref_name }}*{tar.gz,whl} diff --git a/README.md b/README.md index 4c5c672..85da9a5 100644 --- a/README.md +++ b/README.md @@ -2,39 +2,386 @@ > OCR-D wrapper for prima-page-to-pdf -Transforms all PAGE-XML+IMG to PDF with text layer and (optionally) polygon outlines. +[![Python CI](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/ci.yml/badge.svg)](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/ci.yml) +[![Docker CD](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/docker.yml/badge.svg)](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/docker.yml) +[![PyPI CD](https://img.shields.io/pypi/v/ocrd-pagetopdf.svg)](https://pypi.org/project/ocrd-pagetopdf/) -(Converts original images together with text and layout annotations of all pages in the PAGE input file group to PDF. The text is rendered as an overlay.) +Contents: + * [Introduction](#introduction) + * [Requirements](#requirements) + * [Installation](#installation) + * [With Docker](#with-docker) + * [Native, from PyPI](#native-from-pypi) + * [Native, from git](#native-from-git) + * [Usage](#usage) + * [ocrd-pagetopdf](#ocrd-pagetopdf) + * [ocrd-altotopdf](#ocrd-altotopdf) + * [FAQ](#faq) -### Requirements +## Introduction + +This package offers [OCR-D](https://ocr-d.de/en/spec) compliant +[workspace processors](https://ocr-d.de/en/spec/cli) for conversion of OCR data +represented in [METS](https://ocr-d.de/en/spec/mets) (on the document level) +and [PAGE](https://ocr-d.de/en/spec/page) +or [ALTO](https://www.loc.gov/standards/alto/) +(on the page level) to PDF. + +It transforms both the scan image (_facsimile_) and annotations (_text overlay_), +optionally drawing _polygon outlines_ for text regions / lines / words / glyphs. + +Optionally _validates_ the structural annotation and fixes its coordinates before +attempting conversion. + +The text layer is generated from the textual annotation on the configured _level_ +of the structural hierarchy (region / line / word / glyph). It is rendered with a +configurable _font_ (which is useful to make sure all codepoints are covered by +adequate glyphs, esp. in historic prints and manuscripts). + +The _page labels_ can be configured to use various attributes from the +physical pages of the METS. + +A _table of contents_ will be added according to the labels of the +recursive `mets:div` logical structure. + +## Requirements - GNU `make` - Python 3 with `pip` and `venv` - [OCR-D](https://github.com/OCR-D/core) -- Java runtime (OpenJDK 8 works for [PageToPdf](https://github.com/PRImA-Research-Lab/prima-page-to-pdf/releases) 1.1.2) +- Java runtime (OpenJDK ≥8 works for [PageToPdf](https://github.com/PRImA-Research-Lab/prima-page-to-pdf/releases) 1.1.2) + +## Installation + +### With Docker + +This is the best option if you want to run the software in a container. + +You need to have [Docker](https://docs.docker.com/install/linux/docker-ce/ubuntu/) + + + docker pull ocrd/pagetopdf + + +To run with docker: + + + docker run -v path/to/workspaces:/data ocrd/pagetopdf ocrd-pagetopdf ... + +### Native, from PyPI + +This is the best option if you want to use the stable, released version. + +After installing Python and Java, simply do: + + + pip install ocrd_pagetopdf -### Installation -Once you have installed Java, make, Python, and set up your [virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/), do: +### Native, from git - make deps # or: pip install ocrd - make install # copies into PREFIX or VIRTUAL_ENV +Use this option if you want to change the source code or install the latest, unpublished changes. -### Usage +We strongly recommend to use [venv](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/). -The command-line interface conforms to [OCR-D processor](https://ocr-d.de/en/spec/cli) specifications. +After installing `make`, assuming you are on a Debian/Ubuntu OS, you can do: + + sudo make deps-ubuntu + +Otherwise, simulate this step and install requirements with equivalent actions on your system: + + make -n deps-ubuntu + ... + +Finally, to install the Python package, do: + + make install + # or equivalently: + pip install . + + +## Usage + +The command-line interface `ocrd-pagetopdf` conforms to [OCR-D processor](https://ocr-d.de/en/spec/cli) specifications. Assuming you have an [OCR-D workspace](https://ocr-d.de/en/user_guide#preparing-a-workspace) in your current working directory, simply do: - ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP -p '{"textequiv_level" : "word"}' + ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP -P textequiv_level word This will run the script and create PDF files for each page with a text layer based on word-level annotations. -There is also an option to create an additional multipage file with name `merged.pdf`, which contain all single pages in correct order: +In order to create an additional multipage file for the entire document, named `merged.pdf`, +concatenating the single page PDFs in physical order and with page labels and contents, do: + + ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP -P textequiv_level word -P multipage merged + +In case your workspace does not contain fulltext in **PAGE** format, but **ALTO**, there is a dedicated +processor CLI `ocrd-altotopdf`, with some limitations compared to the former: + +- You need to _manually_ select the fileGrp providing the images which match the annotation coordinates, + passing it as second input fileGrp. (The image references are required by PAGE, but not by ALTO.) +- The images are _not_ generated on-the-fly according to all annotations (from existing `AlternativeImage`s, + or by cropping via coordinates into the higher-level image, and deskewing when applicable), and _not_ + chosen via `input_feature_selector` / `input_feature_filter` mechanism. Instead, only the original + images can be used here. +- The annotations are _not_ tested comprehensively regarding validity and consistency of coordinates and + then repaired. Instead, only superficial checks and repairs can be applied (like negative coordinates). + +Assuming you have a workspace representing a typical [DFG-conforming](https://dfg-viewer.de/) METS, +with `FULLTEXT` for ALTO and `DEFAULT` for the original images, do: + + ocrd-altotopdf -I FULLTEXT,DEFAULT -O PDF-FILEGRP -P textequiv_level word -P multipage merged + +For more options and explanations, see below. + +### ocrd-pagetopdf + +

OCR-D CLI + + +
+Usage: ocrd-pagetopdf [worker|server] [OPTIONS]
+
+  Convert text and layout annotations from PAGE to PDF format (overlaying original image with text layer and polygon outlines)
+
+  > Converts all pages of the document to PDF
+
+  > For each page, open and deserialize PAGE input file and its
+  > respective image. Then extract a derived image of the (cropped,
+  > deskewed, binarized...) page, with features depending on
+  > ``image_feature_selector`` (a comma-separated list of required image
+  > features, cf. :py:func:`ocrd.workspace.Workspace.image_from_page`)
+  > and ``image_feature_filter`` (a comma-separated list of forbidden
+  > image features).
 
-    ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP -p '{"textequiv_level" : "word", "multipage":"merged"}'
+  > Next, generate a temporary PAGE output file for that very image
+  > (adapting all coordinates if necessary). If ``negative2zero`` is
+  > set, validate and repair invalid or inconsistent coordinates.
 
-### FAQ
+  > Convert the PAGE/image pair with PRImA PageToPdf, applying
+  > - ``textequiv_level`` (i.e. `-text-source`) to retrieve a text layer, if set;
+  > - ``outlines`` to draw boundary polygons, if set;
+  > - ``font`` accordingly.
+
+  > Copy the resulting PDF file to the output file group and reference
+  > it in the METS.
+
+  > Finally, if ``multipage`` is set, then concatenate all generated
+  > files to a multi-page PDF file, setting ``pagelabels`` accordingly,
+  > as well as PDF metadata and bookmarks. Reference it with
+  > ``multipage`` as ID in the output file group, too. If
+  > ``multipage_only`` is also set, then remove the single-page PDF
+  > files afterwards.
+
+Subcommands:
+    worker      Start a processing worker rather than do local processing
+    server      Start a processor server rather than do local processing
+
+Options for processing:
+  -m, --mets URL-PATH             URL or file path of METS to process [./mets.xml]
+  -w, --working-dir PATH          Working directory of local workspace [dirname(URL-PATH)]
+  -I, --input-file-grp USE        File group(s) used as input
+  -O, --output-file-grp USE       File group(s) used as output
+  -g, --page-id ID                Physical page ID(s) to process instead of full document []
+  --overwrite                     Remove existing output pages/images
+                                  (with "--page-id", remove only those).
+                                  Short-hand for OCRD_EXISTING_OUTPUT=OVERWRITE
+  --debug                         Abort on any errors with full stack trace.
+                                  Short-hand for OCRD_MISSING_OUTPUT=ABORT
+  --profile                       Enable profiling
+  --profile-file PROF-PATH        Write cProfile stats to PROF-PATH. Implies "--profile"
+  -p, --parameter JSON-PATH       Parameters, either verbatim JSON string
+                                  or JSON file path
+  -P, --param-override KEY VAL    Override a single JSON object key-value pair,
+                                  taking precedence over --parameter
+  -U, --mets-server-url URL       URL of a METS Server for parallel incremental access to METS
+                                  If URL starts with http:// start an HTTP server there,
+                                  otherwise URL is a path to an on-demand-created unix socket
+  -l, --log-level [OFF|ERROR|WARN|INFO|DEBUG|TRACE]
+                                  Override log level globally [INFO]
+  --log-filename LOG-PATH         File to redirect stderr logging to (overriding ocrd_logging.conf).
+
+Options for information:
+  -C, --show-resource RESNAME     Dump the content of processor resource RESNAME
+  -L, --list-resources            List names of processor resources
+  -J, --dump-json                 Dump tool description as JSON
+  -D, --dump-module-dir           Show the 'module' resource location path for this processor
+  -h, --help                      Show this message
+  -V, --version                   Show version
+
+Parameters:
+   "image_feature_selector" [string - ""]
+    comma-separated list of required image features (e.g.
+    binarized,despeckled,cropped,deskewed,rotated-90)
+   "image_feature_filter" [string - ""]
+    comma-separated list of forbidden image features (e.g.
+    binarized,despeckled,cropped,deskewed,rotated-90)
+   "font" [string - ""]
+    Font file to be used in PDF file. If unset, AletheiaSans.ttf is used.
+    (Make sure to pick a font which covers all glyphs!)
+   "outlines" [string - ""]
+    What segment hierarchy to draw coordinate outlines for. If unset, no
+    outlines are drawn.
+    Possible values: ["", "region", "line", "word", "glyph"]
+   "textequiv_level" [string - ""]
+    What segment hierarchy level to render text output from. If unset, no
+    text is rendered.
+    Possible values: ["", "region", "line", "word", "glyph"]
+   "negative2zero" [boolean - false]
+    Repair invalid or inconsistent coordinates before trying to convert.
+   "ext" [string - ".pdf"]
+    Output filename extension
+   "multipage" [string - ""]
+    Merge all PDFs into one multipage file. The value is used as METS
+    file ID and file basename for the PDF.
+   "multipage_only" [boolean - false]
+    When producing a `multipage`, do not add single-page files into the
+    output fileGrp (but use a temporary directory for them).
+   "pagelabel" [string - "pageId"]
+    Parameter for 'multipage': Set the labels used as page outlines.
+
+    - 'pageId': physical page ID,
+
+    - 'pagenumber': use consecutive numbers,
+
+    - 'pagelabel': use '@ORDERLABEL - @LABEL',
+
+    - 'basename': use the name of the input file,
+
+    - 'local_filename': use the href relative path of the input file,
+
+    - 'url': use the href URL of the input file,
+
+    - 'ID': use the file ID of the input file
+    Possible values: ["pagenumber", "pagelabel", "pageId", "basename",
+    "basename_without_extension", "local_filename", "ID", "url"]
+   "script-args" [string - ""]
+    Extra arguments to PageToPdf (see https://github.com/PRImA-Research-
+    Lab/prima-page-to-pdf)
+
+ +
+ +### ocrd-altotopdf + +
OCR-D CLI + + +
+Usage: ocrd-altotopdf [worker|server] [OPTIONS]
+
+  Convert text and layout annotations from ALTO to PDF format (overlaying original image with text layer and polygon outlines)
+
+  > Converts all pages of the document to PDF
+
+  > For each page, find the ALTO input file in the first fileGrp,
+  > together with the image input file in the second fileGrp.
+
+  > Then convert ALTO to PAGE with PRImA PageConverter in a temporary
+  > location.
+
+  > Next convert the PAGE/image pair with PRImA PageToPdf in a temporary location,
+  > applying
+  > - ``textequiv_level`` (i.e. `-text-source`) to retrieve a text layer, if set;
+  > - ``outlines`` to draw boundary polygons, if set;
+  > - ``font`` accordingly;
+  > - ``negative2zero`` (i.e. `-neg-coords toZero`) to repair negative coordintes.
+
+  > Copy to the resulting PDF file to the output file group and
+  > reference it in the METS.
+
+  > Finally, if ``multipage`` is set, then concatenate all generated
+  > files to a multi-page PDF file, setting ``pagelabels`` accordingly,
+  > as well as PDF metadata and bookmarks. Reference it with
+  > ``multipage`` as ID in the output fileGrp, too. If
+  > ``multipage_only`` is also set, then remove the single-page PDF
+  > files afterwards.
+
+Subcommands:
+    worker      Start a processing worker rather than do local processing
+    server      Start a processor server rather than do local processing
+
+Options for processing:
+  -m, --mets URL-PATH             URL or file path of METS to process [./mets.xml]
+  -w, --working-dir PATH          Working directory of local workspace [dirname(URL-PATH)]
+  -I, --input-file-grp USE        File group(s) used as input
+  -O, --output-file-grp USE       File group(s) used as output
+  -g, --page-id ID                Physical page ID(s) to process instead of full document []
+  --overwrite                     Remove existing output pages/images
+                                  (with "--page-id", remove only those).
+                                  Short-hand for OCRD_EXISTING_OUTPUT=OVERWRITE
+  --debug                         Abort on any errors with full stack trace.
+                                  Short-hand for OCRD_MISSING_OUTPUT=ABORT
+  --profile                       Enable profiling
+  --profile-file PROF-PATH        Write cProfile stats to PROF-PATH. Implies "--profile"
+  -p, --parameter JSON-PATH       Parameters, either verbatim JSON string
+                                  or JSON file path
+  -P, --param-override KEY VAL    Override a single JSON object key-value pair,
+                                  taking precedence over --parameter
+  -U, --mets-server-url URL       URL of a METS Server for parallel incremental access to METS
+                                  If URL starts with http:// start an HTTP server there,
+                                  otherwise URL is a path to an on-demand-created unix socket
+  -l, --log-level [OFF|ERROR|WARN|INFO|DEBUG|TRACE]
+                                  Override log level globally [INFO]
+  --log-filename LOG-PATH         File to redirect stderr logging to (overriding ocrd_logging.conf).
+
+Options for information:
+  -C, --show-resource RESNAME     Dump the content of processor resource RESNAME
+  -L, --list-resources            List names of processor resources
+  -J, --dump-json                 Dump tool description as JSON
+  -D, --dump-module-dir           Show the 'module' resource location path for this processor
+  -h, --help                      Show this message
+  -V, --version                   Show version
+
+Parameters:
+   "font" [string - ""]
+    Font file to be used in PDF file. If unset, AletheiaSans.ttf is used.
+    (Make sure to pick a font which covers all glyphs!)
+   "outlines" [string - ""]
+    What segment hierarchy to draw coordinate outlines for. If unset, no
+    outlines are drawn.
+    Possible values: ["", "region", "line", "word", "glyph"]
+   "textequiv_level" [string - ""]
+    What segment hierarchy level to render text output from. If unset, no
+    text is rendered.
+    Possible values: ["", "region", "line", "word", "glyph"]
+   "negative2zero" [boolean - false]
+    Repair invalid or inconsistent coordinates before trying to convert.
+   "ext" [string - ".pdf"]
+    Output filename extension
+   "multipage" [string - ""]
+    Merge all PDFs into one multipage file. The value is used as METS
+    file ID and file basename for the PDF.
+   "multipage_only" [boolean - false]
+    When producing a `multipage`, do not add single-page files into the
+    output fileGrp (but use a temporary directory for them).
+   "pagelabel" [string - "pageId"]
+    Parameter for 'multipage': Set the labels used as page outlines.
+
+    - 'pageId': physical page ID,
+
+    - 'pagenumber': use consecutive numbers,
+
+    - 'pagelabel': use '@ORDERLABEL - @LABEL',
+
+    - 'basename': use the name of the input file,
+
+    - 'local_filename': use the href relative path of the input file,
+
+    - 'url': use the href URL of the input file,
+
+    - 'ID': use the file ID of the input file
+    Possible values: ["pagenumber", "pagelabel", "pageId", "basename",
+    "basename_without_extension", "local_filename", "ID", "url"]
+   "script-args" [string - ""]
+    Extra arguments to PageToPdf (see https://github.com/PRImA-Research-
+    Lab/prima-page-to-pdf)
+
+ +
+ + +## FAQ - `Illegal reflective access by com.itextpdf.text.io.ByteBufferRandomAccessSource$1 to method java.nio.DirectByteBuffer.cleaner()` If that appears, try installing OpenJDK 8. @@ -42,13 +389,16 @@ There is also an option to create an additional multipage file with name `merged - `java.lang.NullPointerException` If that appears, try (a little workaround) and set negative coordinates to zero: - ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP -p '{"textequiv_level" : "word", "negative2zero": true}' + ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP ... -P negative2zero true - Some letters are illegible? Please note that the standard displayed font ([AletheiaSans.ttf](https://github.com/PRImA-Research-Lab/prima-aletheia-web/raw/master/war/aletheiasans-webfont.ttf)) does not support all Unicode glyphs. In case yours are missing, set a (monospace) Unicode font yourself: + + ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP ... -P font /usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf + + Fonts can also be referenced by file name if they are installed as [processor resources](https://ocr-d.de/en/spec/cli#processor-resources). A number of options have been preconfigured, cf. `ocrd resmgr list-available -e ocrd-pagetopdf`. - ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP -p '{"textequiv_level" : "word", "font": "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf"}' - -- The multipage file pagelabelnames can be changed, e.g. consecutively pagenumber. +- The multipage file's page labels can be configured, e.g. consecutively via `pagelabel=pagenumber` or from `@ORDERLABEL` and `@LABEL` via `pagelabel=pagelabel`: + + ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP ... -P pagelabel pagelabel - ocrd-pagetopdf -I PAGE-FILGRP -O PDF-FILEGRP -p '{"textequiv_level" : "word", "multipage":"merged", "pagelabelname":"pagenumber"}' From 057be92af260bf6b49ffa6edc3e91d94ef538a0b Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 4 Mar 2025 18:00:52 +0100 Subject: [PATCH 27/29] setuptools: adapt pkg discovery to repo subdirectory --- pyproject.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9a47142..41efe8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,12 +35,20 @@ ocrd-altotopdf = "ocrd_pagetopdf.cli:ocrd_altotopdf" Homepage = "https://github.com/UB-Mannheim/ocrd_pagetopdf" Repository = "https://github.com/UB-Mannheim/ocrd_pagetopdf.git" +[tool.setuptools] +# It is not possible anymore to use autodiscovery, because other directories +# (repo/) will abort flat-layout detection. +# However, neither is it possible to use packages.find (with include/exclude), +# because it fails to accept pure data directories (lib/, data/). +# Hence this clumsy manual enumeration: +packages = ["ocrd_pagetopdf", "ocrd_pagetopdf.lib", "ocrd_pagetopdf.data"] [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} [tool.setuptools.package-data] -"*" = ["PageToPdf.jar", "*.ttf", "*.txt", "*.otf", "ocrd-tool.json"] +"*" = ["PageToPdf.jar", "*.txt", "ocrd-tool.json"] +"ocrd_pagetopdf.data" = ["*.ttf", "*.otf"] "ocrd_pagetopdf.lib" = ["*.jar"] [tool.mypy] From 7f0d04cea722b663a50ce7b07f471fde744d8a1c Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Tue, 4 Mar 2025 18:03:45 +0100 Subject: [PATCH 28/29] fix+improve dockerfile --- Dockerfile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1db3a1c..6cb3911 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,13 +11,24 @@ LABEL \ ENV DEBIAN_FRONTEND noninteractive +# avoid HOME/.local/share (hard to predict USER here) +# so let XDG_DATA_HOME coincide with fixed system location +# (can still be overridden by derived stages) +ENV XDG_DATA_HOME /usr/local/share +# avoid the need for an extra volume for persistent resource user db +# (i.e. XDG_CONFIG_HOME/ocrd/resources.yml) +ENV XDG_CONFIG_HOME /usr/local/share/ocrd-resources + WORKDIR /build/ocrd_pagetopdf -COPY setup.py . +COPY pyproject.toml . COPY ocrd_pagetopdf ./ocrd_pagetopdf COPY ocrd-tool.json . COPY requirements.txt . COPY README.md . COPY Makefile . +# prepackage ocrd-tool.json as ocrd-all-tool.json +RUN python -c "import json; print(json.dumps(json.load(open('ocrd-tool.json'))['tools'], indent=2))" > $(dirname $(ocrd bashlib filename))/ocrd-all-tool.json +# install everything and reduce image size RUN make deps-ubuntu deps install \ && rm -fr /build/ocrd_pagetopdf From 702161496f40c8737b12f7feb0ab481ef9f79ce6 Mon Sep 17 00:00:00 2001 From: Robert Sachunsky Date: Wed, 5 Mar 2025 18:08:22 +0100 Subject: [PATCH 29/29] =?UTF-8?q?prepare=20for=20Github=20transfer=20UB-Ma?= =?UTF-8?q?nnheim=E2=86=92OCR-D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- README.md | 4 ++-- pyproject.toml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6cb3911..9c94796 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ ARG BUILD_DATE LABEL \ maintainer="https://ocr-d.de/kontakt" \ org.label-schema.vcs-ref=$VCS_REF \ - org.label-schema.vcs-url="https://github.com/UB-Mannheim/ocrd_pagetopdf" \ + org.label-schema.vcs-url="https://github.com/OCR-D/ocrd_pagetopdf" \ org.label-schema.build-date=$BUILD_DATE ENV DEBIAN_FRONTEND noninteractive diff --git a/README.md b/README.md index 85da9a5..da0f113 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ > OCR-D wrapper for prima-page-to-pdf -[![Python CI](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/ci.yml/badge.svg)](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/ci.yml) -[![Docker CD](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/docker.yml/badge.svg)](https://github.com/UB-Mannheim/ocrd_pagetopdf/actions/workflows/docker.yml) +[![Python CI](https://github.com/OCR-D/ocrd_pagetopdf/actions/workflows/ci.yml/badge.svg)](https://github.com/OCR-D/ocrd_pagetopdf/actions/workflows/ci.yml) +[![Docker CD](https://github.com/OCR-D/ocrd_pagetopdf/actions/workflows/docker.yml/badge.svg)](https://github.com/OCR-D/ocrd_pagetopdf/actions/workflows/docker.yml) [![PyPI CD](https://img.shields.io/pypi/v/ocrd-pagetopdf.svg)](https://pypi.org/project/ocrd-pagetopdf/) Contents: diff --git a/pyproject.toml b/pyproject.toml index 41efe8c..1728230 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ ocrd-pagetopdf = "ocrd_pagetopdf.cli:ocrd_pagetopdf" ocrd-altotopdf = "ocrd_pagetopdf.cli:ocrd_altotopdf" [project.urls] -Homepage = "https://github.com/UB-Mannheim/ocrd_pagetopdf" -Repository = "https://github.com/UB-Mannheim/ocrd_pagetopdf.git" +Homepage = "https://github.com/OCR-D/ocrd_pagetopdf" +Repository = "https://github.com/OCR-D/ocrd_pagetopdf.git" [tool.setuptools] # It is not possible anymore to use autodiscovery, because other directories