"""Continuous whole-book reader screen — mirrors the web reader's single scrollable view (see blocks.py's module docstring) instead of the old per-chapter pagination, so reading position is expressed in the same "blockIndex:innerFraction" anchor space as the server and the web client. Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the whole book into one Static: for large books (tens of thousands of blocks) a single giant renderable made Textual's layout/paint pass take upwards of a minute, whereas the Line API only ever renders the rows on screen. Reading position (docs/progress-sync.md, rules in progress.py): the place last read wins. The reader saves a position only when the *reader* moved it (keys, wheel), stamped with the moment of arriving; restoring, re-wrapping on resize and jumping on an offer change nothing. While a book is open the server is asked now and then; a newer position from another device is offered ("Auf X weitergelesen — springen?"), never applied by itself. """ from __future__ import annotations import bisect import re import threading from pathlib import Path from rich.console import Console from textual import work from textual.binding import Binding from textual.containers import Container, VerticalScroll from textual.geometry import Size from textual.screen import ModalScreen, Screen from textual.widgets import Footer, Header, Label, LoadingIndicator, Static from . import annotations, blocks, cache, config as config_mod, epub, layout, library, library_meta, marks, progress, remote from .annotation_screens import AnnotationsScreen, NoteScreen from .book_view import ContinuousBookView from .statusbar import StatusBar MAX_LINE_WIDTH = 120 AUTOSAVE_INTERVAL = 5.0 # How often the server is asked for a newer position while the book is open (the apps pull # at most every 2 minutes). REMOTE_CHECK_INTERVAL = 120.0 # "Weiteste Stelle" is offered when the furthest place is this much of the book ahead. FURTHEST_OFFER_SHARE = 0.01 # ContinuousBookView forces its vertical scrollbar always-on (see book_view.py) # so this stays constant — measuring the container's width and subtracting # this fixed amount avoids a measure-after-constrain race against book_view's # own (possibly already-constrained-from-a-previous-layout) width. SCROLLBAR_GUTTER = 2 # A selection may span this many paragraphs (each is drawn again at every cursor move). MAX_MARK_BLOCKS = 200 _EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1) class FootnoteScreen(ModalScreen[None]): BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")] def __init__(self, notes: list[str]) -> None: super().__init__() self._notes = notes def compose(self): with VerticalScroll(id="footnote-body"): for note in self._notes: yield Static(note, classes="footnote-note") yield Label("Escape/f zum Schließen", id="footnote-hint") def action_dismiss_self(self) -> None: self.dismiss(None) class JumpOfferScreen(ModalScreen[bool]): """A question the reader answers with a key; nothing moves unless the answer is yes.""" BINDINGS = [ Binding("y,enter", "answer(True)", "Springen"), Binding("n,escape,q", "answer(False)", "Hier bleiben"), ] def __init__(self, text: str) -> None: super().__init__() self._text = text def compose(self): with Container(id="offer-body"): yield Static(self._text) yield Label("y/Enter: springen n/Escape: hier bleiben", id="offer-hint") def action_answer(self, yes: bool) -> None: self.dismiss(yes) class ReaderScreen(Screen): BINDINGS = [ Binding("j,down", "scroll_down_line", "Runter", show=False), Binding("k,up", "scroll_up_line", "Hoch", show=False), Binding("n", "next_chapter", "Nächstes Kapitel"), Binding("p", "prev_chapter", "Vorheriges Kapitel"), Binding("f", "peek_footnote", "Fußnote"), Binding("w", "goto_furthest", "Weiteste Stelle"), Binding("v", "select", "Markieren"), Binding("b", "bookmark", "Lesezeichen"), Binding("a", "annotations", "Markierungen"), Binding("h,left", "word_back", "Wort zurück", show=False), Binding("l,right", "word_forward", "Wort vor", show=False), Binding("enter,space", "confirm", "Weiter", show=False), Binding("q,escape", "back", "Zurück zur Bibliothek"), ] def __init__(self, path: Path) -> None: super().__init__() self.path = path self.book: blocks.FlatBook | None = None self.book_layout: layout.BookLayout = _EMPTY_LAYOUT self._key = epub._book_id(path) self._server_id = epub.server_id_of(path) self._cfg = config_mod.load() if self._server_id is not None else None self._store = progress.default() # The position the view is on, as last saved or restored, and when the reader last # moved it (this machine's clock). A change of the anchor is a reading position only # when there is such a move. self._last_anchor: str | None = None self._last_move_ms: int | None = None # read_at (server clock) of the state this view is based on: a newer one from the # server is what gets offered. self._held_read_at: int | None = None self._offer_open = False self._marks: marks.MarkIndex | None = None self._ann = annotations.default() self._styled: set[int] = set() # blocks drawn with marks at the moment self._sel: dict | None = None # the selection being made: phase, cursor, start self._ann_timer = None def compose(self): yield Header() yield LoadingIndicator(id="reader-loading") with Container(id="reader-scroll") as scroll_container: self._scroll_container = scroll_container # Stays visible (empty) from the start rather than toggling display # on once loaded — a widget that's just been switched from hidden # to visible hasn't been through a layout pass yet, so its `.size` # is still (0, 0) and an immediate scroll_to() right after has # nothing to clamp against and silently resets to 0. self._book_view = ContinuousBookView(_EMPTY_LAYOUT, on_read_scroll=self._on_read_scroll) yield self._book_view yield StatusBar() yield Footer() def on_mount(self) -> None: # Deferred rather than read synchronously here: right after mount the # container hasn't been through a layout pass yet, so its size isn't # reliable — same lesson as the scroll-restore race below. self.call_after_refresh(self._start_loading) def _start_loading(self) -> None: if self._scroll_container.size.width == 0: # Not sized yet after all — keep deferring instead of guessing a # fixed number of refresh cycles (mirrors _restore_position below). self.call_after_refresh(self._start_loading) return self._load_book(self._content_width()) @work(thread=True) def _load_book(self, width: int) -> None: cached = cache.load(self.path, width) if cached is not None: book, book_layout = cached else: book = blocks.load_flat_book(self.path) console = Console(width=width) book_layout = layout.build_layout([b.text for b in book.blocks], width, console) cache.save(self.path, width, book, book_layout) self.app.call_from_thread(self._on_book_loaded, book, book_layout, width) def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None: self.book = book self.sub_title = book.title self._apply_layout(book_layout, width) self.query_one("#reader-loading", LoadingIndicator).display = False library_meta.set_local_pages(self._key, library.pages_of_chars(book.total_chars)) st = self._store.get(self._key) self._held_read_at = st.read_at if st else None self._marks = marks.MarkIndex(book) self._apply_marks() if self._cfg is not None: self._pull_annotations() self.call_after_refresh(self._restore_position) self.set_interval(AUTOSAVE_INTERVAL, self._autosave) if self._cfg is not None: self.set_interval(REMOTE_CHECK_INTERVAL, self._check_remote) def _content_width(self) -> int: # Measuring the *container* rather than book_view's own size avoids a # measure-after-constrain race: once a layout has been applied, # book_view's width is pinned to a previous value via styles.width # (below), so re-measuring book_view itself on a later resize would # just read that stale pinned width back instead of the new # available space. The container's width is unaffected by that. available = self._scroll_container.size.width - SCROLLBAR_GUTTER return max(20, min(MAX_LINE_WIDTH, available)) def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None: self.book_layout = book_layout # Pin book_view's outer width to content-width-plus-scrollbar so its # *inner* content region (what render_line actually draws into) ends # up exactly `width` — matching what block texts were wrapped at. self._book_view.styles.width = width + SCROLLBAR_GUTTER self._book_view.book_layout = book_layout self._book_view.virtual_size = Size(width, book_layout.total_rows) self._book_view.refresh() def _build_layout(self) -> None: assert self.book is not None width = self._content_width() console = Console(width=width) block_texts = [b.text for b in self.book.blocks] new_layout = layout.build_layout(block_texts, width, console) cache.save(self.path, width, self.book, new_layout) self._apply_layout(new_layout, width) self._styled = set() # a fresh layout is drawn without marks self._apply_marks() def on_resize(self) -> None: if self.book is None: return old_y = self._book_view.scroll_y anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y) self._build_layout() new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac) with self._book_view.program_scroll(): self._book_view.scroll_to(y=new_y, animate=False, immediate=True) def _scroll_to_anchor(self, anchor: str) -> bool: parsed = blocks.parse_anchor(anchor) if parsed is None: return False y = layout.scroll_y_for_anchor(self.book_layout, *parsed) with self._book_view.program_scroll(): self._book_view.scroll_to(y=y, animate=False, immediate=True) return y > 0 def _restore_position(self, attempt: int = 0) -> None: if self.book is None: return st = self._store.get(self._key) if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None: self._settle() return parsed = blocks.parse_anchor(st.anchor) y = layout.scroll_y_for_anchor(self.book_layout, *parsed) if y <= 0: self._settle() return with self._book_view.program_scroll(): self._book_view.scroll_to(y=y, animate=False, immediate=True) # A widget that's only just become part of the layout doesn't always # honor an immediate scroll on the first attempt (its own size/scroll # bounds can still be mid-update) — verify it actually landed and # retry a bounded number of times rather than guessing a fixed delay. if attempt < 20 and abs(self._book_view.scroll_y - y) > 1: self.call_after_refresh(lambda: self._restore_position(attempt + 1)) return self._settle() self._offer_furthest(st) def _settle(self) -> None: """The view is where the saved position put it: from here a changed anchor with a move by the reader is a reading position.""" self._last_anchor = self._current_anchor_str() self._last_move_ms = None def _current_anchor_str(self) -> str: block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) return blocks.format_anchor(block_index, inner_fraction) # ---- saving def _on_read_scroll(self) -> None: self._last_move_ms = progress.now_ms() def _autosave(self) -> None: self._save_progress() def _save_progress(self) -> None: if self.book is None or not self.book_layout.heights or self._last_anchor is None: return anchor = self._current_anchor_str() if anchor == self._last_anchor: self._last_move_ms = None # moved and came back: nothing new return moved_at = self._last_move_ms self._last_anchor = anchor self._last_move_ms = None if moved_at is None: return # the layout moved it, not the reader block_index, inner = blocks.parse_anchor(anchor) fraction = blocks.fraction_for_anchor(self.book, block_index, inner) self._store.write(self._key, anchor, fraction, moved_at, queue=self._cfg is not None) st = self._store.get(self._key) if st is not None: self._held_read_at = st.read_at if self._cfg is not None: self._push() @work(thread=True, exclusive=True, group="progress-push") def _push(self) -> None: try: remote.flush_pending(self._cfg) except Exception: return # best-effort — the position is saved locally and stays queued self.app.call_from_thread(self._after_push) def _after_push(self) -> None: # What the server stored of ours is no news; a newer position of another device in its # answer (our write was refused) is. st = self._store.get(self._key) if st is not None and st.echo_read_at is not None and st.echo_read_at >= (self._held_read_at or 0): self._held_read_at = st.echo_read_at self._offer_newer() def on_unmount(self) -> None: self._save_progress() if self._cfg is not None and (self._store.pending_keys() or self._ann.dirty_keys()): # The screen's workers die with it; the queue still has to go out. threading.Thread(target=self._flush_quietly, args=(self._cfg,), daemon=False).start() @staticmethod def _flush_quietly(cfg) -> None: for send in (remote.flush_pending, remote.push_annotations): try: send(cfg) except Exception: pass # ---- the server's newer positions @work(thread=True, exclusive=True, group="progress-check") def _check_remote(self) -> None: try: remote.sync_library(self.path.parent, self._cfg) except Exception: return # offline etc.: ask again later self.app.call_from_thread(self._offer_newer) def _offer_newer(self) -> None: if self.book is None or self._offer_open or self._last_anchor is None: return st = self._store.get(self._key) if st is None or not st.anchor or st.read_at is None: return if self._held_read_at is not None and st.read_at <= self._held_read_at: return parsed = blocks.parse_anchor(st.anchor) if parsed is None: return shown_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) self._held_read_at = st.read_at # asked once per newer state if parsed[0] == shown_block: return pct = round(blocks.fraction_for_anchor(self.book, *parsed) * 100) who = st.device or "einem anderen Gerät" self._ask(f"Auf {who} weitergelesen — bei {pct} %.\nSpringen?", st.anchor) def _offer_furthest(self, st: progress.BookState) -> None: if self.book is None or not st.furthest_anchor: return here, there = blocks.parse_anchor(st.anchor), blocks.parse_anchor(st.furthest_anchor) if there is None: return share = blocks.fraction_for_anchor(self.book, *there) - (blocks.fraction_for_anchor(self.book, *here) if here else 0.0) if share >= FURTHEST_OFFER_SHARE: pct = round(blocks.fraction_for_anchor(self.book, *there) * 100) self.notify(f"Weiteste Stelle: {pct} % — mit w springen.", timeout=6) def _ask(self, text: str, anchor: str) -> None: self._offer_open = True def answered(yes: bool | None) -> None: self._offer_open = False if yes: self._scroll_to_anchor(anchor) self._last_anchor = self._current_anchor_str() self._last_move_ms = None self.app.push_screen(JumpOfferScreen(text), answered) def action_goto_furthest(self) -> None: st = self._store.get(self._key) if self.book is None or st is None or not st.furthest_anchor: self.notify("Keine weiteste Stelle bekannt.", timeout=3) return if blocks.parse_anchor(st.furthest_anchor) is None: return self._scroll_to_anchor(st.furthest_anchor) # A jump, not reading: nothing is saved until the reader carries on from there. self._last_anchor = self._current_anchor_str() self._last_move_ms = None # ---- navigation def action_scroll_down_line(self) -> None: if self._sel is not None: self._move_block(1) return self._book_view.scroll_relative(y=1, animate=False) def action_scroll_up_line(self) -> None: if self._sel is not None: self._move_block(-1) return self._book_view.scroll_relative(y=-1, animate=False) def _current_chapter_index(self) -> int: if self.book is None: return 0 block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1 def _jump_to_block(self, block_index: int) -> None: y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0) self._book_view.scroll_to(y=y, animate=False, immediate=True) def action_next_chapter(self) -> None: if self._sel is not None: self._selection_note() # n: a note, while marking return if self.book is None: return chapter = self._current_chapter_index() if chapter + 1 < len(self.book.chapter_start_block): self._jump_to_block(self.book.chapter_start_block[chapter + 1]) self._save_progress() def action_prev_chapter(self) -> None: if self.book is None: return chapter = self._current_chapter_index() if chapter > 0: self._jump_to_block(self.book.chapter_start_block[chapter - 1]) self._save_progress() def action_peek_footnote(self) -> None: if self.book is None or not self.book_layout.heights: return current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes] backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes] candidate = forward[0] if forward else (backward[-1] if backward else None) if candidate is None: self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3) return _, block = candidate notes: list[str] = [] for ref in block.footnotes: target_idx = self.book.footnote_targets.get(ref.target_id) if target_idx is None: continue notes.append(self.book.blocks[target_idx].text) if not notes: self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3) return self.app.push_screen(FootnoteScreen(notes)) def action_back(self) -> None: if self._sel is not None: self._end_selection() return self.app.pop_screen() # ---- marks on screen def _marks_by_block(self) -> dict[int, list]: spans: dict[int, list] = {} if self._marks is not None: for bi, lst in self._marks.spans(self._ann.get(self._key).highlights).items(): spans.setdefault(bi, []).extend(lst) for bi, lst in self._selection_spans().items(): spans.setdefault(bi, []).extend(lst) return spans def _apply_marks(self) -> None: """Draw the highlights (and the selection) in place; blocks that lost theirs are drawn plain.""" if self.book is None or not self.book_layout.heights: return spans = self._marks_by_block() console = Console(width=self.book_layout.width) for bi in sorted(self._styled | set(spans)): if 0 <= bi < len(self.book.blocks): layout.restyle_block(self.book_layout, bi, self.book.blocks[bi].text, console, spans.get(bi, ())) self._styled = set(spans) self._book_view.refresh() @work(thread=True, exclusive=True, group="ann-pull") def _pull_annotations(self) -> None: if remote.pull_annotations(self._cfg, self._server_id, self._key): self.app.call_from_thread(self._apply_marks) def _annotations_changed(self) -> None: """A highlight, note or bookmark was made, changed or deleted: draw it, and send it soon.""" self._apply_marks() if self._cfg is None: return if self._ann_timer is not None: self._ann_timer.stop() self._ann_timer = self.set_timer(2.0, self._push_annotations) # like the web's debounce @work(thread=True, exclusive=True, group="ann-push") def _push_annotations(self) -> None: try: remote.push_annotations(self._cfg) except Exception: pass # stays queued # ---- bookmarks and the list def action_bookmark(self) -> None: if self.book is None or not self.book_layout.heights or self._sel is not None: return block, inner = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) chapter = max(0, self._current_chapter_index()) src = self.book.chapter_srcs[chapter] if chapter < len(self.book.chapter_srcs) else "" label = re.sub(r"\.x?html?$", "", src.rsplit("/", 1)[-1], flags=re.I) or "Lesezeichen" # as the web names it bm = annotations.new_bookmark(label, src, blocks.fraction_for_anchor(self.book, block, inner)) self._ann.add(self._key, "bookmarks", bm) self.notify(f"★ Lesezeichen: {label}", timeout=2.5) self._annotations_changed() def action_annotations(self) -> None: if self.book is None or self._sel is not None: return def picked(block: int | None) -> None: if block is not None: self._jump_quiet(block) self.app.push_screen(AnnotationsScreen(self.book, self._ann, self._key, self._annotations_changed, self._marks), picked) def _jump_quiet(self, block: int) -> None: """Go to a block as a jump, not as reading: nothing is saved until the reader moves on.""" y = layout.scroll_y_for_anchor(self.book_layout, block, 0.0) with self._book_view.program_scroll(): self._book_view.scroll_to(y=y, animate=False, immediate=True) self._last_anchor = self._current_anchor_str() self._last_move_ms = None # ---- selecting text (v): a cursor by word, Enter sets the start, Enter again the end def _first_block_in_view(self) -> int: y = self._book_view.scroll_y i = bisect.bisect_left(self.book_layout.starts, y) return min(i, len(self.book.blocks) - 1) def _word_block(self, block: int, step: int) -> int | None: """The nearest block from `block` (inclusive) in direction `step` that has a word.""" while 0 <= block < len(self.book.blocks): if marks.word_starts(self.book.blocks[block].text): return block block += step return None def action_select(self) -> None: if self.book is None or not self.book_layout.heights: return if self._sel is not None: self.action_confirm() return block = self._word_block(self._first_block_in_view(), 1) if block is None: return self._sel = {"phase": "start", "cursor": (block, marks.word_starts(self.book.blocks[block].text)[0]), "start": None} self.notify("Markieren: h/l Wort, j/k Absatz, Enter = Anfang, n = Notiz, Esc = Abbrechen", timeout=6) self._selection_moved() def _end_selection(self) -> None: self._sel = None self._apply_marks() def _selection_moved(self) -> None: self._apply_marks() block, char = self._sel["cursor"] text = self.book.blocks[block].text row = self.book_layout.starts[block] + int(char / max(1, len(text)) * self.book_layout.heights[block]) top, height = self._book_view.scroll_y, self._book_view.size.height if not (top <= row < top + height - 1): with self._book_view.program_scroll(): self._book_view.scroll_to(y=max(0, row - height // 3), animate=False, immediate=True) self._last_anchor = self._current_anchor_str() def action_word_forward(self) -> None: self._move_word(1) def action_word_back(self) -> None: self._move_word(-1) def _move_word(self, step: int) -> None: if self._sel is None: return block, char = self._sel["cursor"] text = self.book.blocks[block].text target = marks.next_word(text, char) if step > 0 else marks.prev_word(text, char) if target is not None: self._sel["cursor"] = (block, target) else: nb = self._word_block(block + step, step) if nb is None: return ws = marks.word_starts(self.book.blocks[nb].text) self._sel["cursor"] = (nb, ws[0] if step > 0 else ws[-1]) self._selection_moved() def _move_block(self, step: int) -> None: nb = self._word_block(self._sel["cursor"][0] + step, step) if nb is not None: self._sel["cursor"] = (nb, marks.word_starts(self.book.blocks[nb].text)[0]) self._selection_moved() def _cursor_word(self) -> tuple[int, int, int]: block, char = self._sel["cursor"] w = marks.word_at(self.book.blocks[block].text, char) or (char, char) return block, w[0], w[1] def _selection_range(self) -> tuple[tuple[int, int], tuple[int, int]]: """(start, end) of what is selected, end exclusive, whichever way the cursor went.""" sb, sc = self._sel["start"] sw = marks.word_at(self.book.blocks[sb].text, sc) or (sc, sc) cb, cs, ce = self._cursor_word() if (cb, cs) >= (sb, sw[0]): return (sb, sw[0]), (cb, ce) return (cb, cs), (sb, sw[1]) def _selection_spans(self) -> dict[int, list]: if self._sel is None or self.book is None: return {} if self._sel["phase"] == "start": block, s, e = self._cursor_word() return {block: [(s, e, marks.SELECTION_STYLE)]} (sb, sc), (eb, ec) = self._selection_range() out: dict[int, list] = {} for bi in range(sb, eb + 1): text_len = len(self.book.blocks[bi].text) out[bi] = [(sc if bi == sb else 0, ec if bi == eb else text_len, marks.SELECTION_STYLE)] return out def action_confirm(self) -> None: if self._sel is None: return if self._sel["phase"] == "start": block, s, _e = self._cursor_word() self._sel["start"] = (block, s) self._sel["phase"] = "end" self.notify("Ende wählen (h/l/j/k), Enter = fertig.", timeout=4) self._selection_moved() return (sb, sc), (eb, ec) = self._selection_range() if eb - sb > MAX_MARK_BLOCKS: self.notify(f"Zu lang zum Markieren (mehr als {MAX_MARK_BLOCKS} Absätze).", severity="warning") return anchor = self._marks.anchor((sb, sc), (eb, ec)) if anchor is None: self.notify("Diese Stelle lässt sich nicht genau zuordnen (Fußnotenmarker mit Leerraum).", severity="warning") return names = [annotations.COLOR_NAMES[c] for c in annotations.COLORS] + ["Gelb mit Notiz …"] colors = list(annotations.COLORS) def picked(i: int | None) -> None: if i is None: return # the selection stays: pick again or Esc if i < len(colors): self._add_highlight(anchor, colors[i], "") else: self.app.push_screen(NoteScreen("Notiz zur Markierung"), lambda text: self._add_highlight(anchor, "yellow", text) if text is not None else None) from .library_screen import ChoiceScreen self.app.push_screen(ChoiceScreen("Markieren als", names, 0), picked) def _add_highlight(self, anchor: dict, color: str, note: str) -> None: self._ann.add(self._key, "highlights", annotations.new_highlight(anchor, color, note)) self._sel = None self._annotations_changed() def _selection_note(self) -> None: """n while marking: a freeform note at the cursor's word (phase 1) or on the selection.""" if self._sel["phase"] == "start": block, s, e = self._cursor_word() start, end = (block, s), (block, e) else: start, end = self._selection_range() anchor = self._marks.anchor(start, end) if anchor is None: self.notify("Diese Stelle lässt sich nicht genau zuordnen.", severity="warning") return def noted(text: str | None) -> None: if text: self._ann.add(self._key, "highlights", annotations.new_note(anchor, text)) self._sel = None self._annotations_changed() self.app.push_screen(NoteScreen("Notiz"), noted)