Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cc90077
Testcase for #52: Support remote images
hnesk Feb 21, 2023
c5b5fef
Testcase for #52: cleanup
hnesk Feb 21, 2023
94a5bb5
Testcase for #52: cleanup
hnesk Feb 21, 2023
8d8dcb3
Remove get_file_index and use OcrdMets new builtin caching
hnesk Feb 21, 2023
fba5419
Moved the problem closer to solution (doc.get_image_paths -> PageList…
hnesk Feb 21, 2023
02a04a2
download remote files on demand
Feb 27, 2023
7eb4242
Merge branch 'bertsky-try-workspace-download' into support_remote_images
hnesk Feb 27, 2023
9c01137
Two testcases: One with single small remote resource, and one with se…
hnesk Feb 27, 2023
834b073
allow_download=True will download files
hnesk Feb 27, 2023
88da0ce
directly use document.get_image_files
hnesk Feb 27, 2023
4be9a61
small refactorings
hnesk Feb 27, 2023
bcbcbb8
new icon "downloading" and renaming "loading" to "generating-thumb"
hnesk Mar 2, 2023
694a6fe
code formatting
hnesk Mar 2, 2023
197555c
refactored LazyLoading to support remote image downloading
hnesk Mar 2, 2023
68f2907
PAGE view order arrows: avoid zero division when region is zero
Nov 17, 2025
d4a9f0f
enable debug logging
Nov 17, 2025
4698585
XML view: add pretty-print toggle
Nov 17, 2025
a7ac0ba
Merge remote-tracking branch 'upstream/master' into support_remote_im…
Nov 17, 2025
7cadd4a
serve: start maximised and without window decorations
May 23, 2024
2483813
dockerfile: py38 instead of py37
May 23, 2024
83a08f3
tests: adapt to changes in core:26967052 (loglevel name)
May 23, 2024
44c9b0a
document: adapt to core@cfd1c915 (refresh_caches now hidden)
May 23, 2024
0b4230d
test_document test_save: workaround for core#1149
May 24, 2024
6566be2
test_document test_save: workaround for core#1226
May 24, 2024
5ce98b3
tests/example METS files: use file paths instead of URLs
May 26, 2024
0dcfb3a
Page view renderer: recursive RO with star for unordered and recursiv…
Nov 21, 2025
d769500
get_files: warn+filter if download fails
Nov 21, 2025
79b0b86
docker-build: depend on update of gresources
May 30, 2024
e5ff6e3
PageView tooltip: also show TableCellRole attributes, if any
Jul 14, 2023
7b96ecd
page view screenshot button: default to page id instead of 'untitled'
Nov 28, 2025
4ded593
adapt to ocrd v3 OcrdPage wrapper
Dec 5, 2025
42eb522
adapt to pydantic v2
Dec 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.7
FROM python:3.8

WORKDIR /
COPY Makefile .
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ tests/assets: repo/assets
mkdir -p $@
cp -r -t $@ repo/assets/data/*

docker-build:
docker-build: ocrd_browser/ui.gresource
docker build --tag $(DOCKER_TAG) .

docker-run: DATADIR ?= $(CURDIR)
Expand Down
Binary file added gresources/icons/page-downloading.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified gresources/icons/page-icons.xcf
Binary file not shown.
3 changes: 2 additions & 1 deletion gresources/ocrd-browser.gresource.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
<gresource prefix="/org/readmachine/ocrd-browser">
<file compressed="true" preprocess="to-pixdata">icons/logo.png</file>
<file compressed="true" preprocess="to-pixdata">icons/icon.png</file>
<file compressed="true" preprocess="to-pixdata">icons/page-loading.png</file>
<file compressed="true" preprocess="to-pixdata">icons/page-missing.png</file>
<file compressed="true" preprocess="to-pixdata">icons/page-downloading.png</file>
<file compressed="true" preprocess="to-pixdata">icons/page-generating-thumb.png</file>
<file compressed="true" preprocess="xml-stripblanks">icons/split-horizontal.svg</file>
<file compressed="true" preprocess="xml-stripblanks">icons/split-vertical.svg</file>
<file compressed="true" preprocess="xml-stripblanks">icons/icon-feature-border.svg</file>
Expand Down
2 changes: 2 additions & 0 deletions ocrd_browser/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ def main() -> None:
if PROFILER:
GLib.idle_add(startup_time)
from ocrd_utils import initLogging
import logging
initLogging()
logging.getLogger('ocrd').setLevel(logging.DEBUG)
from ocrd_browser.application import OcrdBrowserApplication
install_excepthook()
app = OcrdBrowserApplication()
Expand Down
116 changes: 62 additions & 54 deletions ocrd_browser/model/document.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations
from typing import Optional, Tuple, List, Union, cast, Callable, Any, Dict, Sequence, TYPE_CHECKING

from contextlib import contextmanager

from typing import Optional, Tuple, List, Union, cast, Callable, Any, Dict, Sequence, Generator, TYPE_CHECKING

import atexit
import errno
Expand Down Expand Up @@ -41,6 +44,17 @@
EventCallBack = Optional[Callable[[str, Any], None]]


@contextmanager
def caching_environment(value: str = '1') -> Generator[None, None, None]:
backup_caching = os.environ.get('OCRD_METS_CACHING', None)
os.environ['OCRD_METS_CACHING'] = value
try:
yield
finally:
if backup_caching is not None:
os.environ['OCRD_METS_CACHING'] = backup_caching


def check_editable(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def guard(self: 'Document', *args: List[Any], **kwargs: Dict[Any, Any]) -> Any:
Expand Down Expand Up @@ -81,7 +95,9 @@ def load(cls, mets_url: Union[Path, str] = None, emitter: EventCallBack = None)
return cls.create(emitter=emitter)
mets_path = cls._to_path(mets_url)

workspace = Resolver().workspace_from_url(str(mets_path), download=False)
with caching_environment('1'):
workspace = Resolver().workspace_from_url(str(mets_path), download=False)

doc = cls(workspace, emitter=emitter, original_url=str(mets_url))
doc._empty = False
return doc
Expand All @@ -106,7 +122,10 @@ def _clone_workspace(cls, mets_url: Union[Path, str]) -> Workspace:
cls.temporary_workspaces.append(temporary_workspace)
# TODO download = False and lazy loading would be nice for responsiveness
log.info("Cloning '%s' to '%s'", mets_url, temporary_workspace)
workspace = Resolver().workspace_from_url(mets_url=mets_url, dst_dir=temporary_workspace, download=True)

with caching_environment('1'):
workspace = Resolver().workspace_from_url(mets_url=mets_url, dst_dir=temporary_workspace, download=True)

return workspace

@check_editable
Expand Down Expand Up @@ -166,13 +185,18 @@ def baseurl_mets(self) -> str:
"""
return str(self.workspace.baseurl) + '/' + self.mets_filename if self.workspace else None

def path(self, other: Union[OcrdFile, Path, str]) -> Optional[Path]:
def path(self, other: Union[OcrdFile, Path, str], allow_download: bool = False) -> Optional[Path]:
"""
Resolves other relative to current workspace
"""
if not self.directory:
return None
if isinstance(other, OcrdFile):
if not other.local_filename:
if allow_download:
other = self.workspace.download_file(other)
else:
raise ValueError('path with allow_download=False for non local file called: #{}: {}'.format(other.ID, other.url))
return self.directory.joinpath(other.local_filename)
elif isinstance(other, Path):
return self.directory.joinpath(other)
Expand Down Expand Up @@ -219,49 +243,26 @@ def file_groups(self) -> List[FileGroupHandle]:
def title(self) -> str:
return str(self.workspace.mets.unique_identifier) if self.workspace and self.workspace.mets.unique_identifier else '<unnamed>'

def get_file_index(self) -> Dict[str, OcrdFile]:
def get_image_files(self, file_group: FileGroupHandle, allow_download: bool = False) -> Dict[str, Optional[OcrdFile]]:
"""
Return all OcrdFiles by file id and additionally augments the OcrdFile with static_page_id for fast(er) lookup

Example:
page17 = [file for file in file_index.values() if file.static_page_id == 'PHYS_0017']

Builds a Dict PageID->OcrdFile|None for all page_ids
"""
log = getLogger('ocrd_browser.model.document.Document.get_file_index')
file_index = {}
log = getLogger('ocrd_browser.model.document.Document.get_image_files')
image_paths: Dict[str, Optional[OcrdFile]] = {}
if self.workspace:
for file in self.workspace.mets.find_files():
file.static_page_id = None
file_index[file.ID] = file

file_pointers: List[Element] = self.xpath(
'mets:structMap[@TYPE="PHYSICAL"]/mets:div[@TYPE="physSequence"]/mets:div[@TYPE="page"]/mets:fptr')
for file_pointer in file_pointers:
file_id = file_pointer.get('FILEID')
page_id = file_pointer.getparent().get('ID')
if file_id in file_index:
file_index[file_id].static_page_id = page_id
else:
log.warning("FILEID '%s' for PAGE '%s' not in mets:fileSec", file_id, page_id)

return file_index

def get_image_paths(self, file_group: FileGroupHandle) -> Dict[str, Path]:
"""
Builds a Dict ID->Path for all page_ids fast
for page_id in self.page_ids:
for file in self.workspace.mets.find_files(pageId=page_id, fileGrp=file_group.group, mimetype=file_group.mime):
if page_id in image_paths:
log.warning('Multiple files for PAGE %s and fileGrp %s, using first %s', page_id, file_group, image_paths[page_id])
else:
if not file.local_filename:
if allow_download:
file = self.workspace.download_file(file)
image_paths[page_id] = file
if page_id not in image_paths:
log.warning('Found no files for PAGE %s and fileGrp %s', page_id, file_group)
image_paths[page_id] = None

More precisely: fast = Faster than iterating over page_ids and using mets.get_physical_page_for_file for each entry
"""
log = getLogger('ocrd_browser.model.document.Document.get_image_paths')
image_paths = {}
file_index = self.get_file_index()
for page_id in self.page_ids:
images = [image for image in file_index.values() if image.static_page_id == page_id and file_group.match(image)]
if len(images) > 0:
image_paths[page_id] = self.directory.joinpath(images[0].local_filename)
else:
log.warning('Found no images for PAGE %s and fileGrp %s', page_id, file_group)
image_paths[page_id] = None
return image_paths

def get_default_image_group(self, preferred_image_file_groups: PatternList = None) -> Optional[FileGroupHandle]:
Expand Down Expand Up @@ -306,11 +307,17 @@ def page_for_id(self, page_id: str, file_group: str) -> Optional['Page']:
return Page(self, page_id, file_group)

def files_for_page_id(self, page_id: str, file_group: str = None, mimetype: str = None) -> List[OcrdFile]:
log = getLogger('ocrd_browser.model.document.Document.files_for_page_id')
with pushd_popd(self.workspace.directory):
files: List[OcrdFile] = self.workspace.mets.find_files(fileGrp=file_group, pageId=page_id,
mimetype=mimetype)
files = [self.workspace.download_file(file) for file in files]
return files
def try_download(file):
try:
return self.workspace.download_file(file)
except FileNotFoundError as e:
log.warning(e)
return None
return list(filter(None, [try_download(file) for file in files]))

def page_for_file(self, page_file: OcrdFile) -> PcGtsType:
# cd and silence Warning: Value "ocrd-cis-word-alignment" ... does not match xsd enumeration restriction on TextDataTypeSimpleType
Expand All @@ -319,7 +326,7 @@ def page_for_file(self, page_file: OcrdFile) -> PcGtsType:

def resolve_image(self, image_file: OcrdFile) -> Image:
with pushd_popd(self.workspace.directory):
pil_image = Image.open(self.workspace.download_file(image_file).local_filename)
pil_image = Image.open(self.path(image_file, allow_download=True))
# pil_image.load()
return pil_image

Expand Down Expand Up @@ -354,7 +361,7 @@ def reorder(self, ordered_page_ids: List[str]) -> None:
page_sequence.append(div)

old_to_new = dict(zip(old_page_ids, self.page_ids))
self.workspace.mets.refresh_caches()
self.workspace.mets._refresh_caches()
self.save_mets()
self._emit('document_changed', 'reordered', old_to_new)

Expand Down Expand Up @@ -418,14 +425,15 @@ def editable(self) -> bool:

@editable.setter
def editable(self, editable: bool) -> None:
if editable:
if self._original_url:
self.workspace = self._clone_workspace(self._original_url)
with caching_environment('1'):
if editable:
if self._original_url:
self.workspace = self._clone_workspace(self._original_url)
else:
# noinspection PyTypeChecker
self.workspace = Resolver().workspace_from_nothing(directory=None, mets_basename='mets.xml')
else:
# noinspection PyTypeChecker
self.workspace = Resolver().workspace_from_nothing(directory=None, mets_basename='mets.xml')
else:
self.workspace = Resolver().workspace_from_url(self.baseurl_mets)
self.workspace = Resolver().workspace_from_url(self.baseurl_mets)
self._editable = editable
# self._empty = False
# self._modified = False
Expand Down
Loading
Loading