442 lines
18 KiB
JavaScript
442 lines
18 KiB
JavaScript
|
|
// The scrolling page strip.
|
||
|
|
//
|
||
|
|
// Built out of pdf.js's PDFPageView components rather than its PDFViewer.
|
||
|
|
// PDFViewer renders `pdfDocument.numPages` pages in the file's own order and
|
||
|
|
// offers no way to hide, reorder or individually rotate one — which is three
|
||
|
|
// of this editor's features. Owning the strip costs us a scroll observer and
|
||
|
|
// a scale calculation; it buys pages that move the instant the user drags a
|
||
|
|
// thumbnail, with no round-trip through the file.
|
||
|
|
//
|
||
|
|
// Two layers are ours rather than PDFPageView's:
|
||
|
|
//
|
||
|
|
// * The text layer. PDFPageView builds one, but tears it down whenever the
|
||
|
|
// page's canvas is discarded — and Theseus's find bar is Chromium's
|
||
|
|
// findInPage over the live DOM, so a torn-down text layer is a page that
|
||
|
|
// Ctrl+F cannot see. We build our own from cached text content and keep it
|
||
|
|
// attached for every page, drawn or not, so find covers the whole
|
||
|
|
// document rather than the dozen pages currently rasterised.
|
||
|
|
//
|
||
|
|
// * The overlay (lib/overlay.js), for the same reason plus hit-testing.
|
||
|
|
//
|
||
|
|
// Canvases ARE virtualised: a letter page at 100% is ~3.4 MB of bitmap, so a
|
||
|
|
// few hundred of them is gigabytes. Only pages near the viewport hold one.
|
||
|
|
|
||
|
|
import { Overlay } from "./overlay.js";
|
||
|
|
|
||
|
|
const GUTTER = 28; // px of breathing room either side at fit-width
|
||
|
|
const DRAW_MARGIN = "180% 0px"; // how far outside the viewport to keep canvases
|
||
|
|
const MAX_PUMP_PASSES = 12; // retry sweeps before the pump gives up
|
||
|
|
const MAX_DRAW_RETRIES = 6; // per-page redraw attempts before we stop asking
|
||
|
|
|
||
|
|
export class PageStrip {
|
||
|
|
constructor({ container, viewer, pdfjsLib, pdf, model, eventBus, layerProperties }) {
|
||
|
|
this.container = container; // the scrolling element
|
||
|
|
this.viewer = viewer; // #viewer, holds the .page divs
|
||
|
|
this.lib = pdfjsLib;
|
||
|
|
this.pdf = pdf;
|
||
|
|
this.model = model;
|
||
|
|
this.eventBus = eventBus;
|
||
|
|
this.layerProperties = layerProperties;
|
||
|
|
this.views = new Map(); // uid -> {pv, overlay, textDiv, textContent, stale}
|
||
|
|
this.scaleMode = "page-width"; // "page-width" | "page-fit" | number
|
||
|
|
this.scale = 1;
|
||
|
|
this.current = null; // uid of the page nearest the top of the view
|
||
|
|
this._onCurrent = [];
|
||
|
|
this._io = null;
|
||
|
|
this._visIo = null;
|
||
|
|
// A hidden tab produces no frames: requestAnimationFrame never runs, the
|
||
|
|
// IntersectionObserver never reports, and pdf.js's own render never
|
||
|
|
// publishes its canvas. Nothing can be done about that while hidden — and
|
||
|
|
// nothing needs to be, since nobody is looking — but the moment the tab
|
||
|
|
// comes back we have to re-measure and rasterise, or it stays blank.
|
||
|
|
this._onVisibility = () => {
|
||
|
|
if (document.hidden) return;
|
||
|
|
this._markVisibleByGeometry();
|
||
|
|
this._pump();
|
||
|
|
};
|
||
|
|
document.addEventListener("visibilitychange", this._onVisibility);
|
||
|
|
}
|
||
|
|
|
||
|
|
onCurrentChange(fn) { this._onCurrent.push(fn); }
|
||
|
|
|
||
|
|
// ---- construction -------------------------------------------------
|
||
|
|
async build() {
|
||
|
|
const { PDFPageView } = this.lib.viewer;
|
||
|
|
for (const uid of this.model.order) {
|
||
|
|
const p = this.model.page(uid);
|
||
|
|
const pdfPage = await this.pdf.getPage(p.src + 1);
|
||
|
|
const defaultViewport = pdfPage.getViewport({ scale: 1 });
|
||
|
|
const pv = new PDFPageView({
|
||
|
|
container: null, // we place the div ourselves
|
||
|
|
id: p.src + 1,
|
||
|
|
scale: 1,
|
||
|
|
defaultViewport,
|
||
|
|
eventBus: this.eventBus,
|
||
|
|
layerProperties: this.layerProperties,
|
||
|
|
// TextLayerMode is internal to the components bundle and not exported,
|
||
|
|
// hence the literal; 0 is DISABLE. We supply our own text layer, for
|
||
|
|
// the find-bar reason at the top of this file.
|
||
|
|
textLayerMode: 0,
|
||
|
|
annotationMode: this.lib.AnnotationMode.ENABLE_FORMS,
|
||
|
|
imageResourcesPath: "vendor/pdfjs/web/images/",
|
||
|
|
enableAutoLinking: false,
|
||
|
|
});
|
||
|
|
pv.setPdfPage(pdfPage);
|
||
|
|
const entry = { pv, pdfPage, overlay: null, textDiv: null, textContent: null,
|
||
|
|
drawn: false, drawing: false, visible: false };
|
||
|
|
this.views.set(uid, entry);
|
||
|
|
}
|
||
|
|
this.computeScale();
|
||
|
|
this.layout();
|
||
|
|
// Start at the top. Without this the container keeps whatever scroll
|
||
|
|
// offset the previous document left behind — and because the observer
|
||
|
|
// only rasterises what is near the viewport, a short document opened
|
||
|
|
// after a long one can land entirely out of view and never draw at all.
|
||
|
|
// Page divs also change height as they take their real dimensions, and
|
||
|
|
// Chromium's scroll anchoring happily follows that, so reset again once
|
||
|
|
// the layout has settled.
|
||
|
|
this.container.scrollTop = 0;
|
||
|
|
this._observe();
|
||
|
|
this._trackCurrent();
|
||
|
|
this._markVisibleByGeometry();
|
||
|
|
this._pump();
|
||
|
|
// Page divs settle into their real heights a frame later; re-measure then
|
||
|
|
// so the second screenful is queued without waiting for a scroll.
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
this.container.scrollTop = 0;
|
||
|
|
this._markVisibleByGeometry();
|
||
|
|
this._pump();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Viewport for a page at the current scale and rotation. */
|
||
|
|
viewportOf(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e) return null;
|
||
|
|
return e.pdfPage.getViewport({
|
||
|
|
scale: this.scale,
|
||
|
|
rotation: this.model.displayRotation(uid),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- scale --------------------------------------------------------
|
||
|
|
computeScale() {
|
||
|
|
const uid = this.current || this.model.visible()[0];
|
||
|
|
if (!uid) { this.scale = 1; return; }
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
const base = e.pdfPage.getViewport({ scale: 1, rotation: this.model.displayRotation(uid) });
|
||
|
|
if (typeof this.scaleMode === "number") { this.scale = this.scaleMode; return; }
|
||
|
|
const availW = Math.max(120, this.container.clientWidth - GUTTER * 2);
|
||
|
|
if (this.scaleMode === "page-width") {
|
||
|
|
this.scale = availW / base.width;
|
||
|
|
} else {
|
||
|
|
const availH = Math.max(120, this.container.clientHeight - GUTTER);
|
||
|
|
this.scale = Math.min(availW / base.width, availH / base.height);
|
||
|
|
}
|
||
|
|
// pdf.js clamps its own viewer to this band; going outside it produces
|
||
|
|
// canvases the compositor refuses or a page too small to read.
|
||
|
|
this.scale = Math.min(10, Math.max(0.1, this.scale));
|
||
|
|
}
|
||
|
|
|
||
|
|
setScaleMode(mode) {
|
||
|
|
this.scaleMode = mode;
|
||
|
|
this.computeScale();
|
||
|
|
this.layout();
|
||
|
|
}
|
||
|
|
/** Step the numeric zoom, switching out of a fit mode if we are in one. */
|
||
|
|
stepScale(dir) {
|
||
|
|
const steps = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6];
|
||
|
|
const at = this.scale;
|
||
|
|
const next = dir > 0 ? steps.find((s) => s > at + 0.001) : [...steps].reverse().find((s) => s < at - 0.001);
|
||
|
|
if (next) this.setScaleMode(next);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- layout -------------------------------------------------------
|
||
|
|
/** Re-place every page div in display order and re-size all layers. */
|
||
|
|
layout() {
|
||
|
|
this.viewer.style.setProperty("--scale-factor", String(this.scale));
|
||
|
|
const visible = this.model.visible();
|
||
|
|
// Detach deleted pages, then append in order. appendChild on an element
|
||
|
|
// already in the tree moves it, so this is also the reorder path.
|
||
|
|
for (const [uid, e] of this.views) {
|
||
|
|
if (!visible.includes(uid)) e.pv.div.remove();
|
||
|
|
}
|
||
|
|
visible.forEach((uid, i) => {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
this.viewer.append(e.pv.div);
|
||
|
|
e.pv.updatePageNumber(i + 1);
|
||
|
|
e.pv.div.dataset.uid = uid;
|
||
|
|
});
|
||
|
|
for (const uid of visible) this.applyPageGeometry(uid);
|
||
|
|
this._observe();
|
||
|
|
this._markVisibleByGeometry();
|
||
|
|
this._pump();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Push the current scale + rotation into one page's view and its layers. */
|
||
|
|
applyPageGeometry(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e) return;
|
||
|
|
const rotation = this.model.extraRotation(uid);
|
||
|
|
// A window resize recomputes a fit scale and lands on a value a hair off
|
||
|
|
// the old one. Re-rendering every page for a difference nobody can see
|
||
|
|
// would also cancel whatever was mid-render, so ignore the noise.
|
||
|
|
const scaleChanged = Math.abs(e.pv.scale - this.scale) > 0.0005;
|
||
|
|
if (scaleChanged || e.pv.rotation !== rotation) {
|
||
|
|
e.pv.update({ scale: this.scale, rotation });
|
||
|
|
e.drawn = false; // update() resets the canvas
|
||
|
|
// update() wipes the div's children, ours included.
|
||
|
|
if (e.textDiv) { e.textDiv.remove(); e.textDiv = null; }
|
||
|
|
if (e.overlay) { e.overlay.destroy(); e.overlay = null; }
|
||
|
|
}
|
||
|
|
this.ensureOverlay(uid);
|
||
|
|
this.refreshOverlay(uid);
|
||
|
|
if (!e.textDiv) this.buildTextLayer(uid);
|
||
|
|
}
|
||
|
|
|
||
|
|
ensureOverlay(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e) return null;
|
||
|
|
if (!e.overlay) e.overlay = new Overlay(e.pv.div);
|
||
|
|
return e.overlay;
|
||
|
|
}
|
||
|
|
refreshOverlay(uid, selectedId = this._selectedId) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e || !e.overlay) return;
|
||
|
|
const viewport = this.viewportOf(uid);
|
||
|
|
if (!viewport) return;
|
||
|
|
this._selectedId = selectedId;
|
||
|
|
let marks = this.model.annotsFor(uid);
|
||
|
|
// The in-progress drag is drawn from the same code as a committed mark, so
|
||
|
|
// what the user sees mid-drag is exactly what lands when they release.
|
||
|
|
if (this._preview && this._preview.uid === uid && this._preview.annot) {
|
||
|
|
marks = marks.concat([{ id: "__preview", ...this._preview.annot }]);
|
||
|
|
}
|
||
|
|
e.overlay.render(viewport, marks, selectedId);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Show a not-yet-committed mark on one page. Pass (null, null) to clear. */
|
||
|
|
setPreview(uid, annot) {
|
||
|
|
const prev = this._preview?.uid;
|
||
|
|
this._preview = uid ? { uid, annot } : null;
|
||
|
|
if (prev && prev !== uid) this.refreshOverlay(prev);
|
||
|
|
if (uid) this.refreshOverlay(uid);
|
||
|
|
}
|
||
|
|
refreshAllOverlays(selectedId) {
|
||
|
|
this._selectedId = selectedId;
|
||
|
|
for (const uid of this.model.visible()) this.refreshOverlay(uid, selectedId);
|
||
|
|
}
|
||
|
|
overlayFor(uid) { return this.views.get(uid)?.overlay || null; }
|
||
|
|
|
||
|
|
// ---- text layer ---------------------------------------------------
|
||
|
|
// Built from cached text content so a re-layout is DOM work only — no
|
||
|
|
// second trip to the worker. Kept attached for every page regardless of
|
||
|
|
// whether its canvas is currently alive.
|
||
|
|
async buildTextLayer(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e || e.textDiv) return;
|
||
|
|
const div = document.createElement("div");
|
||
|
|
div.className = "textLayer";
|
||
|
|
e.textDiv = div;
|
||
|
|
e.pv.div.append(div);
|
||
|
|
const viewport = this.viewportOf(uid);
|
||
|
|
this.lib.setLayerDimensions(div, viewport);
|
||
|
|
try {
|
||
|
|
if (!e.textContent) e.textContent = await e.pdfPage.getTextContent();
|
||
|
|
const tl = new this.lib.TextLayer({ textContentSource: e.textContent, container: div, viewport });
|
||
|
|
await tl.render();
|
||
|
|
} catch (err) {
|
||
|
|
// A page with no extractable text is normal (a scan). Anything else is
|
||
|
|
// worth a line in the console but must not stop the page rendering.
|
||
|
|
if (err?.name !== "AbortException") console.warn(`text layer for ${uid}:`, err?.message || err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- canvas virtualisation ----------------------------------------
|
||
|
|
//
|
||
|
|
// The observer only records what the user can see; the pump decides what to
|
||
|
|
// rasterise. Keeping those apart is what makes this survive a relayout: a
|
||
|
|
// render cancelled because the scale changed mid-flight simply leaves the
|
||
|
|
// page wanted-but-not-drawn, and the next pump picks it up. An earlier
|
||
|
|
// version drew straight from the observer callback, and a resize during
|
||
|
|
// startup — Theseus restoring its window bounds — could cancel the first
|
||
|
|
// render of every page, after which nothing ever asked again and the user
|
||
|
|
// sat looking at blank pages.
|
||
|
|
_observe() {
|
||
|
|
this._io?.disconnect();
|
||
|
|
this._io = new IntersectionObserver((entries) => {
|
||
|
|
for (const ent of entries) {
|
||
|
|
const uid = ent.target.dataset.uid;
|
||
|
|
const e = uid && this.views.get(uid);
|
||
|
|
if (!e) continue;
|
||
|
|
e.visible = ent.isIntersecting;
|
||
|
|
}
|
||
|
|
this._pump();
|
||
|
|
}, { root: this.container, rootMargin: DRAW_MARGIN });
|
||
|
|
for (const uid of this.model.visible()) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (e) this._io.observe(e.pv.div);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Work out what is on screen by measuring, instead of waiting to be told.
|
||
|
|
*
|
||
|
|
* An IntersectionObserver only reports from the browser's rendering step, so
|
||
|
|
* a tab that has not produced a frame yet never gets its first callback —
|
||
|
|
* and an editor whose whole render schedule hangs off that observer then
|
||
|
|
* shows a document of blank pages until something else forces a paint. That
|
||
|
|
* is not hypothetical: it was intermittent on every cold open of a tab. The
|
||
|
|
* observer stays, because it is the efficient way to follow scrolling, but
|
||
|
|
* the first paint no longer depends on it.
|
||
|
|
*/
|
||
|
|
_markVisibleByGeometry() {
|
||
|
|
const root = this.container.getBoundingClientRect();
|
||
|
|
if (!root.height) return;
|
||
|
|
const margin = root.height * 1.8; // matches DRAW_MARGIN
|
||
|
|
for (const uid of this.model.visible()) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e) continue;
|
||
|
|
const r = e.pv.div.getBoundingClientRect();
|
||
|
|
e.visible = r.bottom >= root.top - margin && r.top <= root.bottom + margin;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Bring every page's canvas into line with whether it is currently wanted. */
|
||
|
|
_pump() {
|
||
|
|
if (this._dead) return;
|
||
|
|
if (this._pumping) { this._pumpAgain = true; return; }
|
||
|
|
this._pumping = true;
|
||
|
|
queueMicrotask(async () => {
|
||
|
|
try {
|
||
|
|
let passes = 0;
|
||
|
|
this._pumpAgain = false;
|
||
|
|
do {
|
||
|
|
for (const uid of this.model.visible()) {
|
||
|
|
if (this._dead) return;
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e) continue;
|
||
|
|
if (e.visible && !e.drawn) await this._draw(uid);
|
||
|
|
else if (!e.visible && e.drawn) this._undraw(uid);
|
||
|
|
}
|
||
|
|
if (!this._pumpAgain) break;
|
||
|
|
this._pumpAgain = false;
|
||
|
|
// Hand the frame back between passes. A retry that fails the same
|
||
|
|
// way every time would otherwise spin as fast as pdf.js can reject,
|
||
|
|
// freezing the tab instead of just failing one page. setTimeout, not
|
||
|
|
// rAF: a tab that is not painting never runs a rAF callback, which is
|
||
|
|
// exactly the situation a retry may be recovering from.
|
||
|
|
if (++passes < MAX_PUMP_PASSES) {
|
||
|
|
await new Promise((r) => setTimeout(r, 16));
|
||
|
|
this._pumpAgain = true;
|
||
|
|
} else {
|
||
|
|
console.warn("page rendering gave up after", passes, "passes");
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
} while (this._pumpAgain);
|
||
|
|
} finally { this._pumping = false; }
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async _draw(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e || e.drawn || e.drawing) return;
|
||
|
|
e.drawing = true;
|
||
|
|
try {
|
||
|
|
await e.pv.draw();
|
||
|
|
e.drawn = true;
|
||
|
|
e.retries = 0;
|
||
|
|
} catch (err) {
|
||
|
|
// A cancelled render is routine — the scale or rotation moved under it.
|
||
|
|
// Leave the page undrawn and let the next pump have another go, but
|
||
|
|
// give up eventually so one unrenderable page cannot hold the pump open.
|
||
|
|
if (err?.name !== "RenderingCancelledException") console.warn(`draw ${uid}:`, err?.message || err);
|
||
|
|
if ((e.retries = (e.retries || 0) + 1) <= MAX_DRAW_RETRIES) this._pumpAgain = true;
|
||
|
|
return;
|
||
|
|
} finally { e.drawing = false; }
|
||
|
|
// draw() appends the canvas wrapper and the annotation layer; ours have to
|
||
|
|
// end up above them, and update()/reset() may have removed them entirely.
|
||
|
|
if (this._dead || !this.views.has(uid)) return;
|
||
|
|
if (!e.textDiv) await this.buildTextLayer(uid);
|
||
|
|
else e.pv.div.append(e.textDiv);
|
||
|
|
if (this._dead || !this.views.has(uid)) return;
|
||
|
|
this.ensureOverlay(uid);
|
||
|
|
e.pv.div.append(e.overlay.svg);
|
||
|
|
this.refreshOverlay(uid);
|
||
|
|
}
|
||
|
|
|
||
|
|
_undraw(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e || !e.drawn) return;
|
||
|
|
e.drawn = false;
|
||
|
|
// Drop the canvas and the form layer; keep ours. reset() removes every
|
||
|
|
// child it was not told to keep, so re-attach after.
|
||
|
|
e.pv.reset();
|
||
|
|
if (e.textDiv) e.pv.div.append(e.textDiv);
|
||
|
|
if (e.overlay) e.pv.div.append(e.overlay.svg);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Force a redraw of one page — after a rotation, say. */
|
||
|
|
async redraw(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e) return;
|
||
|
|
this.applyPageGeometry(uid);
|
||
|
|
e.drawn = false;
|
||
|
|
this._pump();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- current page tracking ----------------------------------------
|
||
|
|
_trackCurrent() {
|
||
|
|
this._visIo?.disconnect();
|
||
|
|
// A page counts as "current" once its top edge is in the upper half of
|
||
|
|
// the viewport; that matches how every PDF reader reports a page number.
|
||
|
|
this._visIo = new IntersectionObserver((entries) => {
|
||
|
|
let best = null, bestTop = Infinity;
|
||
|
|
for (const ent of entries) {
|
||
|
|
if (!ent.isIntersecting) continue;
|
||
|
|
const top = Math.abs(ent.boundingClientRect.top - ent.rootBounds.top);
|
||
|
|
if (top < bestTop) { bestTop = top; best = ent.target.dataset.uid; }
|
||
|
|
}
|
||
|
|
if (best && best !== this.current) {
|
||
|
|
this.current = best;
|
||
|
|
for (const fn of this._onCurrent) { try { fn(best); } catch {} }
|
||
|
|
}
|
||
|
|
}, { root: this.container, threshold: [0, 0.1, 0.5] });
|
||
|
|
for (const uid of this.model.visible()) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (e) this._visIo.observe(e.pv.div);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
scrollTo(uid) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (e) e.pv.div.scrollIntoView({ block: "start", behavior: "smooth" });
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Which page div is under a client point, and where inside it. */
|
||
|
|
pageAt(clientX, clientY) {
|
||
|
|
for (const uid of this.model.visible()) {
|
||
|
|
const e = this.views.get(uid);
|
||
|
|
if (!e) continue;
|
||
|
|
const r = e.pv.div.getBoundingClientRect();
|
||
|
|
if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
|
||
|
|
return { uid, vx: clientX - r.left, vy: clientY - r.top, rect: r };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
destroy() {
|
||
|
|
this._dead = true;
|
||
|
|
document.removeEventListener("visibilitychange", this._onVisibility);
|
||
|
|
this._io?.disconnect();
|
||
|
|
this._visIo?.disconnect();
|
||
|
|
for (const e of this.views.values()) {
|
||
|
|
try { e.overlay?.destroy(); } catch {}
|
||
|
|
try { e.pv.destroy(); } catch {}
|
||
|
|
}
|
||
|
|
this.views.clear();
|
||
|
|
this.viewer.textContent = "";
|
||
|
|
}
|
||
|
|
}
|