diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d9d3e9c..f2444d2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,6 +22,20 @@ ClearPDF uses open-source components and keeps their notices with the project. R - License: Apache License 2.0 (as attributed in the Settings screen) - Use: the app's translucent glass surfaces, backdrop effects, and shared UI components. +## Google ML Kit Text Recognition + +- Artifact: `com.google.mlkit:text-recognition:16.0.1` +- License: Google APIs Terms of Service (proprietary; the client library, not the on-device model, carries Apache-2.0-style redistribution terms — see Google's ML Kit terms) +- Use: primary on-device OCR engine for scanned/image-only PDF pages (`ocr-core` module). This is the **bundled** artifact — the recognition model ships inside the APK, not the Play-Services-downloaded variant — so it runs fully offline with no network call and no Play Services requirement. +- Notice practice: comply with Google's ML Kit Terms of Service for redistribution; do not imply Google endorses ClearPDF. + +## Tesseract4Android / Tesseract OCR / Leptonica + +- Artifact: `cz.adaptech.tesseract4android:tesseract4android:4.9.0` (Copyright 2019 Adaptech s.r.o., Robert Pösel) +- License: Apache License 2.0 (wraps the Tesseract OCR engine, also Apache-2.0, and the Leptonica imaging library, BSD-2-Clause) +- Use: fully open-source, offline OCR fallback (`ocr-core` module) used when the bundled ML Kit engine fails to initialize or recognize on a given device. Bundles `eng.traineddata` (English) as a module asset so the fallback needs no download. +- Notice practice: keep the Apache 2.0 license text and the BSD-2-Clause Leptonica notice available with redistributed builds. + ## Feature-set inspiration — Pdf_Tools - Project: `Karna14314/Pdf_Tools` (https://github.com/Karna14314/Pdf_Tools) @@ -29,3 +43,23 @@ ClearPDF uses open-source components and keeps their notices with the project. R - Use: informed ClearPDF's on-device tool set (e.g. PDF-to-Images export). ClearPDF's implementations are original code written against the app's own architecture and `backdrop` UI; no source was copied. This acknowledgement is provided in good faith for the shared feature direction. The app does not add GPL or LGPL components for document rendering. If a future dependency changes that, its license and redistribution obligations must be reviewed before release. + +## docx-preview (docxjs) + +- Artifact: `docx-preview.min.js` 0.4.0, vendored at `app/src/main/assets/docx/` +- License: Apache License 2.0 +- Source: https://github.com/VolodymyrBaydalka/docxjs +- Use: lays out .docx documents inside an offscreen WebView, which is then printed to PDF. Chosen + over a native Office engine purely on size — `app.opendocument:odr-core-android` is a 100 MB AAR + and Apache POI's OOXML half is ~17 MB of jars that only parse, not render. +- Notice practice: the upstream Apache-2.0 banner is preserved verbatim at the top of the vendored + file, and the library is credited in Settings → Licenses. + +## JSZip + +- Artifact: `jszip.min.js` 3.10.1, vendored at `app/src/main/assets/docx/` +- License: MIT (upstream is dual MIT / GPL-3.0; this app uses it under the MIT option) +- Source: https://github.com/Stuk/jszip +- Use: required by docx-preview to read the .docx zip container in the browser context. +- Notice practice: the upstream banner (including its pako attribution) is preserved verbatim at the + top of the vendored file, and the library is credited in Settings → Licenses. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1013ce3..9a1f99c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,6 +76,20 @@ android { buildFeatures { compose = true } + // Bundling on-device OCR (bundled ML Kit + Tesseract4Android) added native .so libs for + // 4 CPU architectures; without splitting, every install carries all 4. This produces one + // APK per ABI (~1/4 the native-lib weight each) plus a universal fallback for sideloading. + // Play Store distribution via an Android App Bundle (`./gradlew bundleRelease`) already does + // this automatically and needs no config here — this `splits` block only matters for raw + // APK builds/installs (`assembleDebug`/`assembleRelease`, `installDebug`, sideloading). + splits { + abi { + isEnable = true + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + isUniversalApk = true + } + } packaging { resources { excludes += arrayOf( @@ -128,6 +142,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(project(":backdrop")) implementation(project(":pdf-core")) + implementation(project(":ocr-core")) // Apache POI provides legacy .doc/.xls/.ppt text extraction. It is Apache-2.0 // licensed; see THIRD_PARTY_NOTICES.md for redistribution requirements. implementation("org.apache.poi:poi:3.17") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 96343ca..9f9c54a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -30,3 +30,18 @@ -keep class com.google.mlkit.** { *; } -keep class com.google.android.gms.internal.mlkit_vision_document_scanner.** { *; } -dontwarn com.google.mlkit.** + +# ── .docx viewer (DocxWebRenderer) ── +# These two live in `android.print` on purpose: the print framework's result callbacks have +# package-private constructors, and being in that package is the only way to subclass them and +# drive a PrintDocumentAdapter without the system print dialog. R8 renaming or repackaging them +# would move them out of `android.print` and the access check would fail at runtime — on release +# builds only, which is the worst way to find out. `-keep` pins both the name and the package. +-keep class android.print.OpenLayoutResultCallback { *; } +-keep class android.print.OpenWriteResultCallback { *; } + +# The WebView bridge is only ever called from JavaScript, so nothing in the app references these +# methods and R8 would otherwise consider them unused. +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} diff --git a/app/src/main/assets/docx/docx-preview.min.js b/app/src/main/assets/docx/docx-preview.min.js new file mode 100644 index 0000000..26db72f --- /dev/null +++ b/app/src/main/assets/docx/docx-preview.min.js @@ -0,0 +1,8 @@ +/* + * @license + * docx-preview + * Released under Apache License 2.0 + * Copyright Volodymyr Baydalka + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("jszip")):"function"==typeof define&&define.amd?define(["exports","jszip"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).docx={},e.JSZip)}(this,function(e,t){"use strict";var r;function a(e){return/^[^"'].*\s.*[^"']$/.test(e)?`'${e}'`:e}function s(e){let t=e.lastIndexOf("/")+1;return[0==t?"":e.substring(0,t),0==t?e:e.substring(t)]}function n(e,t){try{const r="http://docx/";return new URL(e,r+t).toString().substring(r.length)}catch{return`${t}${e}`}}function l(e,t){return e.reduce((e,r)=>(e[t(r)]=r,e),{})}function o(e){return e&&"object"==typeof e&&!Array.isArray(e)}function i(e){return"string"==typeof e||e instanceof String}function c(e,...t){if(!t.length)return e;const r=t.shift();if(o(e)&&o(r))for(const t in r)if(o(r[t])){c(e[t]??(e[t]={}),r[t])}else e[t]=r[t];return c(e,...t)}function h(e){return Array.isArray(e)?e:[e]}!function(e){e.OfficeDocument="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",e.FontTable="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable",e.Image="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",e.Numbering="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",e.Styles="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",e.StylesWithEffects="http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects",e.Theme="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",e.Settings="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings",e.WebSettings="http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings",e.Hyperlink="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",e.Footnotes="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes",e.Endnotes="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",e.Footer="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",e.Header="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",e.ExtendedProperties="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",e.CoreProperties="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",e.CustomProperties="http://schemas.openxmlformats.org/package/2006/relationships/metadata/custom-properties",e.Comments="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",e.CommentsExtended="http://schemas.microsoft.com/office/2011/relationships/commentsExtended",e.AltChunk="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk"}(r||(r={}));const m="http://schemas.openxmlformats.org/wordprocessingml/2006/main",u={mul:.05,unit:"pt"},p={mul:1/12700,unit:"pt"},d={mul:.5,unit:"pt"},g={mul:.125,unit:"pt",min:.25,max:12},f={mul:1,unit:"pt"},b={mul:.02,unit:"%"};function y(e,t=u){if(null==e||/.+(p[xt]|[%])$/.test(e))return e;var r=parseInt(e)*t.mul;return t.min&&t.max&&(r=function(e,t,r){return t>e?t:rfunction(e,t){let r={name:t.attr(e,"name"),embedFontRefs:[]};for(let a of t.elements(e))switch(a.localName){case"family":r.family=t.attr(a,"val");break;case"altName":r.altName=t.attr(a,"val");break;case"embedRegular":case"embedBold":case"embedItalic":case"embedBoldItalic":r.embedFontRefs.push(x(a,t))}return r}(e,t))}function x(e,t){return{id:t.attr(e,"id"),key:t.attr(e,"fontKey"),type:S[e.localName]}}class N extends P{parseXml(e){this.fonts=M(e,this._package.xmlParser)}}class C{constructor(e,t){this._zip=e,this.options=t,this.xmlParser=new v}get(e){const t=function(e){return e.startsWith("/")?e.substr(1):e}(e);return this._zip.files[t]??this._zip.files[t.replace(/\//g,"\\")]}update(e,t){this._zip.file(e,t)}static async load(e,r){const a=await t.loadAsync(e);return new C(a,r)}save(e="blob"){return this._zip.generateAsync({type:e})}load(e,t="string"){return this.get(e)?.async(t)??Promise.resolve(null)}async loadRelationships(e=null){let t="_rels/.rels";if(null!=e){const[r,a]=s(e);t=`${r}_rels/${a}.rels`}const r=await this.load(t);return r?(a=this.parseXmlDocument(r).firstElementChild,(n=this.xmlParser).elements(a).map(e=>({id:n.attr(e,"Id"),type:n.attr(e,"Type"),target:n.attr(e,"Target"),targetMode:n.attr(e,"TargetMode")}))):null;var a,n}async loadContentTypes(){const e=await this.load("[Content_Types].xml");return e?(t=this.parseXmlDocument(e).firstElementChild,(r=this.xmlParser).elements(t).map(e=>({extension:r.attr(e,"Extension"),partName:r.attr(e,"PartName"),contentType:r.attr(e,"ContentType")}))):[];var t,r}parseXmlDocument(e){return function(e,t=!1){var r;t&&(e=e.replace(/<[?].*[?]>/,"")),e=65279===(r=e).charCodeAt(0)?r.substring(1):r;const a=(new DOMParser).parseFromString(e,"application/xml"),s=(n=a,n.getElementsByTagName("parsererror")[0]?.textContent);var n;if(s)throw new Error(s);return a}(e,this.options.trimXmlDeclaration)}}class T extends P{constructor(e,t,r){super(e,t),this._documentParser=r}parseXml(e){this.body=this._documentParser.parseDocumentFile(e)}}function A(e,t){return{type:t.attr(e,"val"),color:t.attr(e,"color"),size:t.lengthAttr(e,"sz",g),offset:t.lengthAttr(e,"space",f),frame:t.boolAttr(e,"frame"),shadow:t.boolAttr(e,"shadow")}}function E(e,t){var r={};for(let a of t.elements(e))switch(a.localName){case"left":r.left=A(a,t);break;case"top":r.top=A(a,t);break;case"right":r.right=A(a,t);break;case"bottom":r.bottom=A(a,t)}return r}var R,B;function D(e,t=w){var r={};for(let a of t.elements(e))switch(a.localName){case"pgSz":r.pageSize={width:t.lengthAttr(a,"w"),height:t.lengthAttr(a,"h"),orientation:t.attr(a,"orient")};break;case"type":r.type=t.attr(a,"val");break;case"pgMar":r.pageMargins={left:t.lengthAttr(a,"left"),right:t.lengthAttr(a,"right"),top:t.lengthAttr(a,"top"),bottom:t.lengthAttr(a,"bottom"),header:t.lengthAttr(a,"header"),footer:t.lengthAttr(a,"footer"),gutter:t.lengthAttr(a,"gutter")};break;case"cols":r.columns=L(a,t);break;case"headerReference":(r.headerRefs??(r.headerRefs=[])).push(F(a,t));break;case"footerReference":(r.footerRefs??(r.footerRefs=[])).push(F(a,t));break;case"titlePg":r.titlePage=t.boolAttr(a,"val",!0);break;case"pgBorders":r.pageBorders=E(a,t);break;case"pgNumType":r.pageNumber=$(a,t)}return r}function L(e,t){return{numberOfColumns:t.intAttr(e,"num"),space:t.lengthAttr(e,"space"),separator:t.boolAttr(e,"sep"),equalWidth:t.boolAttr(e,"equalWidth",!0),columns:t.elements(e,"col").map(e=>({width:t.lengthAttr(e,"w"),space:t.lengthAttr(e,"space")}))}}function $(e,t){return{chapSep:t.attr(e,"chapSep"),chapStyle:t.attr(e,"chapStyle"),format:t.attr(e,"fmt"),start:t.intAttr(e,"start")}}function F(e,t){return{id:t.attr(e,"id"),type:t.attr(e,"type")}}function I(e,t){let r={};for(let a of t.elements(e))H(a,r,t);return r}function H(e,t,r){return!!k(e,t,r)}function O(e,t){let r={};for(let a of t.elements(e))_(a,r,t);return r}function _(e,t,r){if(e.namespaceURI!=m)return!1;if(k(e,t,r))return!0;switch(e.localName){case"tabs":t.tabs=function(e,t){return t.elements(e,"tab").map(e=>({position:t.lengthAttr(e,"pos"),leader:t.attr(e,"leader"),style:t.attr(e,"val")}))}(e,r);break;case"sectPr":t.sectionProps=D(e,r);break;case"numPr":t.numbering=function(e,t){var r={};for(let a of t.elements(e))switch(a.localName){case"numId":r.id=t.attr(a,"val");break;case"ilvl":r.level=t.intAttr(a,"val")}return r}(e,r);break;case"spacing":return t.lineSpacing=function(e,t){return{before:t.lengthAttr(e,"before"),after:t.lengthAttr(e,"after"),line:t.intAttr(e,"line"),lineRule:t.attr(e,"lineRule")}}(e,r),!1;case"textAlignment":return t.textAlignment=r.attr(e,"val"),!1;case"keepLines":t.keepLines=r.boolAttr(e,"val",!0);break;case"keepNext":t.keepNext=r.boolAttr(e,"val",!0);break;case"pageBreakBefore":t.pageBreakBefore=r.boolAttr(e,"val",!0);break;case"outlineLvl":t.outlineLevel=r.intAttr(e,"val");break;case"pStyle":t.styleName=r.attr(e,"val");break;case"rPr":t.runProps=I(e,r);break;default:return!1}return!0}function j(e,t){let r={id:t.attr(e,"numId"),overrides:[]};for(let a of t.elements(e))switch(a.localName){case"abstractNumId":r.abstractId=t.attr(a,"val");break;case"lvlOverride":r.overrides.push(W(a,t))}return r}function z(e,t){let r={id:t.attr(e,"abstractNumId"),levels:[]};for(let a of t.elements(e))switch(a.localName){case"name":r.name=t.attr(a,"val");break;case"multiLevelType":r.multiLevelType=t.attr(a,"val");break;case"numStyleLink":r.numberingStyleLink=t.attr(a,"val");break;case"styleLink":r.styleLink=t.attr(a,"val");break;case"lvl":r.levels.push(V(a,t))}return r}function V(e,t){let r={level:t.intAttr(e,"ilvl")};for(let a of t.elements(e))switch(a.localName){case"start":r.start=t.attr(a,"val");break;case"lvlRestart":r.restart=t.intAttr(a,"val");break;case"numFmt":r.format=t.attr(a,"val");break;case"lvlText":r.text=t.attr(a,"val");break;case"lvlJc":r.justification=t.attr(a,"val");break;case"lvlPicBulletId":r.bulletPictureId=t.attr(a,"val");break;case"pStyle":r.paragraphStyle=t.attr(a,"val");break;case"pPr":r.paragraphProps=O(a,t);break;case"rPr":r.runProps=I(a,t)}return r}function W(e,t){let r={level:t.intAttr(e,"ilvl")};for(let a of t.elements(e))switch(a.localName){case"startOverride":r.start=t.intAttr(a,"val");break;case"lvl":r.numberingLevel=V(a,t)}return r}function X(e,t){var r=t.element(e,"pict"),a=r&&t.element(r,"shape"),s=a&&t.element(a,"imagedata");return s?{id:t.attr(e,"numPicBulletId"),referenceId:t.attr(s,"id"),style:t.attr(a,"style")}:null}!function(e){e.Continuous="continuous",e.NextPage="nextPage",e.NextColumn="nextColumn",e.EvenPage="evenPage",e.OddPage="oddPage"}(R||(R={}));class G extends P{constructor(e,t,r){super(e,t),this._documentParser=r}parseXml(e){Object.assign(this,function(e,t){let r={numberings:[],abstractNumberings:[],bulletPictures:[]};for(let a of t.elements(e))switch(a.localName){case"num":r.numberings.push(j(a,t));break;case"abstractNum":r.abstractNumberings.push(z(a,t));break;case"numPicBullet":r.bulletPictures.push(X(a,t))}return r}(e,this._package.xmlParser)),this.domNumberings=this._documentParser.parseNumberingFile(e)}}class U extends P{constructor(e,t,r){super(e,t),this._documentParser=r}parseXml(e){this.styles=this._documentParser.parseStylesFile(e)}}!function(e){e.Document="document",e.Paragraph="paragraph",e.Run="run",e.Break="break",e.NoBreakHyphen="noBreakHyphen",e.Table="table",e.Row="row",e.Cell="cell",e.Hyperlink="hyperlink",e.SmartTag="smartTag",e.Drawing="drawing",e.Image="image",e.Text="text",e.Tab="tab",e.Symbol="symbol",e.BookmarkStart="bookmarkStart",e.BookmarkEnd="bookmarkEnd",e.Footer="footer",e.Header="header",e.FootnoteReference="footnoteReference",e.EndnoteReference="endnoteReference",e.Footnote="footnote",e.Endnote="endnote",e.SimpleField="simpleField",e.ComplexField="complexField",e.Instruction="instruction",e.VmlPicture="vmlPicture",e.MmlMath="mmlMath",e.MmlMathParagraph="mmlMathParagraph",e.MmlFraction="mmlFraction",e.MmlFunction="mmlFunction",e.MmlFunctionName="mmlFunctionName",e.MmlNumerator="mmlNumerator",e.MmlDenominator="mmlDenominator",e.MmlRadical="mmlRadical",e.MmlBase="mmlBase",e.MmlDegree="mmlDegree",e.MmlSuperscript="mmlSuperscript",e.MmlSubscript="mmlSubscript",e.MmlPreSubSuper="mmlPreSubSuper",e.MmlSubArgument="mmlSubArgument",e.MmlSuperArgument="mmlSuperArgument",e.MmlNary="mmlNary",e.MmlDelimiter="mmlDelimiter",e.MmlRun="mmlRun",e.MmlEquationArray="mmlEquationArray",e.MmlLimit="mmlLimit",e.MmlLimitLower="mmlLimitLower",e.MmlMatrix="mmlMatrix",e.MmlMatrixRow="mmlMatrixRow",e.MmlBox="mmlBox",e.MmlBar="mmlBar",e.MmlGroupChar="mmlGroupChar",e.VmlElement="vmlElement",e.Inserted="inserted",e.Deleted="deleted",e.DeletedText="deletedText",e.Comment="comment",e.CommentReference="commentReference",e.CommentRangeStart="commentRangeStart",e.CommentRangeEnd="commentRangeEnd",e.AltChunk="altChunk"}(B||(B={}));class q{constructor(){this.children=[],this.cssStyle={}}}class J extends q{constructor(){super(...arguments),this.type=B.Header}}class Z extends q{constructor(){super(...arguments),this.type=B.Footer}}class Y extends P{constructor(e,t,r){super(e,t),this._documentParser=r}parseXml(e){this.rootElement=this.createRootElement(),this.rootElement.children=this._documentParser.parseBodyElements(e)}}class K extends Y{createRootElement(){return new J}}class Q extends Y{createRootElement(){return new Z}}function ee(e){if(void 0!==e)return parseInt(e)}class te extends P{parseXml(e){this.props=function(e,t){const r={};for(let a of t.elements(e))switch(a.localName){case"Template":r.template=a.textContent;break;case"Pages":r.pages=ee(a.textContent);break;case"Words":r.words=ee(a.textContent);break;case"Characters":r.characters=ee(a.textContent);break;case"Application":r.application=a.textContent;break;case"Lines":r.lines=ee(a.textContent);break;case"Paragraphs":r.paragraphs=ee(a.textContent);break;case"Company":r.company=a.textContent;break;case"AppVersion":r.appVersion=a.textContent}return r}(e,this._package.xmlParser)}}class re extends P{parseXml(e){this.props=function(e,t){const r={};for(let a of t.elements(e))switch(a.localName){case"title":r.title=a.textContent;break;case"description":r.description=a.textContent;break;case"subject":r.subject=a.textContent;break;case"creator":r.creator=a.textContent;break;case"keywords":r.keywords=a.textContent;break;case"language":r.language=a.textContent;break;case"lastModifiedBy":r.lastModifiedBy=a.textContent;break;case"revision":a.textContent&&(r.revision=parseInt(a.textContent))}return r}(e,this._package.xmlParser)}}class ae{}function se(e,t){var r={name:t.attr(e,"name"),colors:{}};for(let n of t.elements(e)){var a=t.element(n,"srgbClr"),s=t.element(n,"sysClr");a?r.colors[n.localName]=t.attr(a,"val"):s&&(r.colors[n.localName]=t.attr(s,"lastClr"))}return r}function ne(e,t){var r={name:t.attr(e,"name")};for(let a of t.elements(e))switch(a.localName){case"majorFont":r.majorFont=le(a,t);break;case"minorFont":r.minorFont=le(a,t)}return r}function le(e,t){return{latinTypeface:t.elementAttr(e,"latin","typeface"),eaTypeface:t.elementAttr(e,"ea","typeface"),csTypeface:t.elementAttr(e,"cs","typeface")}}class oe extends P{constructor(e,t){super(e,t)}parseXml(e){this.theme=function(e,t){var r=new ae,a=t.element(e,"themeElements");for(let e of t.elements(a))switch(e.localName){case"clrScheme":r.colorScheme=se(e,t);break;case"fontScheme":r.fontScheme=ne(e,t)}return r}(e,this._package.xmlParser)}}class ie{}class ce extends ie{constructor(){super(...arguments),this.type=B.Footnote}}class he extends ie{constructor(){super(...arguments),this.type=B.Endnote}}class me extends P{constructor(e,t,r){super(e,t),this._documentParser=r}}class ue extends me{constructor(e,t,r){super(e,t,r)}parseXml(e){this.notes=this._documentParser.parseNotes(e,"footnote",ce)}}class pe extends me{constructor(e,t,r){super(e,t,r)}parseXml(e){this.notes=this._documentParser.parseNotes(e,"endnote",he)}}function de(e,t){var r={defaultNoteIds:[]};for(let a of t.elements(e))switch(a.localName){case"numFmt":r.nummeringFormat=t.attr(a,"val");break;case"footnote":case"endnote":r.defaultNoteIds.push(t.attr(a,"id"))}return r}class ge extends P{constructor(e,t){super(e,t)}parseXml(e){this.settings=function(e,t){var r={};for(let a of t.elements(e))switch(a.localName){case"defaultTabStop":r.defaultTabStop=t.lengthAttr(a,"val");break;case"footnotePr":r.footnoteProps=de(a,t);break;case"endnotePr":r.endnoteProps=de(a,t);break;case"autoHyphenation":r.autoHyphenation=t.boolAttr(a,"val")}return r}(e,this._package.xmlParser)}}class fe extends P{parseXml(e){this.props=function(e,t){return t.elements(e,"property").map(e=>{const r=e.firstChild;return{formatId:t.attr(e,"fmtid"),name:t.attr(e,"name"),type:r.nodeName,value:r.textContent}})}(e,this._package.xmlParser)}}class be extends P{constructor(e,t,r){super(e,t),this._documentParser=r}parseXml(e){this.comments=this._documentParser.parseComments(e),this.commentMap=l(this.comments,e=>e.id)}}class ye extends P{constructor(e,t){super(e,t),this.comments=[]}parseXml(e){const t=this._package.xmlParser;for(let r of t.elements(e,"commentEx"))this.comments.push({paraId:t.attr(r,"paraId"),paraIdParent:t.attr(r,"paraIdParent"),done:t.boolAttr(r,"done")});this.commentMap=l(this.comments,e=>e.paraId)}}const ke=[{type:r.OfficeDocument,target:"word/document.xml"},{type:r.ExtendedProperties,target:"docProps/app.xml"},{type:r.CoreProperties,target:"docProps/core.xml"},{type:r.CustomProperties,target:"docProps/custom.xml"}];class ve{constructor(){this.parts=[],this.partsMap={},this.contentTypes=[]}static async load(e,t,r){var a=new ve;return a._options=r,a._parser=t,a._package=await C.load(e,r),a.rels=await a._package.loadRelationships(),a.contentTypes=await a._package.loadContentTypes(),await Promise.all(ke.map(e=>{const t=a.rels.find(t=>t.type===e.type)??e;return a.loadRelationshipPart(t.target,t.type)})),a}save(e="blob"){return this._package.save(e)}async loadRelationshipPart(e,t){if(this.partsMap[e])return this.partsMap[e];if(!this._package.get(e))return null;let a=null;switch(t){case r.OfficeDocument:this.documentPart=a=new T(this._package,e,this._parser);break;case r.FontTable:this.fontTablePart=a=new N(this._package,e);break;case r.Numbering:this.numberingPart=a=new G(this._package,e,this._parser);break;case r.Styles:this.stylesPart=a=new U(this._package,e,this._parser);break;case r.Theme:this.themePart=a=new oe(this._package,e);break;case r.Footnotes:this.footnotesPart=a=new ue(this._package,e,this._parser);break;case r.Endnotes:this.endnotesPart=a=new pe(this._package,e,this._parser);break;case r.Footer:a=new Q(this._package,e,this._parser);break;case r.Header:a=new K(this._package,e,this._parser);break;case r.CoreProperties:this.corePropsPart=a=new re(this._package,e);break;case r.ExtendedProperties:this.extendedPropsPart=a=new te(this._package,e);break;case r.CustomProperties:a=new fe(this._package,e);break;case r.Settings:this.settingsPart=a=new ge(this._package,e);break;case r.Comments:this.commentsPart=a=new be(this._package,e,this._parser);break;case r.CommentsExtended:this.commentsExtendedPart=a=new ye(this._package,e)}if(null==a)return Promise.resolve(null);if(this.partsMap[e]=a,this.parts.push(a),await a.load(),a.rels?.length>0){const[e]=s(a.path);await Promise.all(a.rels.map(t=>this.loadRelationshipPart(n(t.target,e),t.type)))}return a}async loadDocumentImage(e,t){const r=this.getPathById(t??this.documentPart,e);return r?this.blobToURL(await this._package.load(r,"blob"),r):null}async loadNumberingImage(e){const t=this.getPathById(this.numberingPart,e);return t?this.blobToURL(await this._package.load(t,"blob"),t):null}async loadFont(e,t){const r=this.getPathById(this.fontTablePart,e);if(!r)return null;const a=await this._package.load(r,"uint8array");return a?this.blobToURL(new Blob([we(a,t)]),r):a}async loadAltChunk(e,t){const r=this.getPathById(t??this.documentPart,e);return r?this._package.load(r,"string"):Promise.resolve(null)}blobToURL(e,t){if(!e)return null;if(t){const r=this.contentTypes.find(e=>e.partName===t||e.extension&&t.endsWith(`.${e.extension}`));e=r?new Blob([e],{type:r.contentType}):e}return this._options.useBase64URL?function(e){return new Promise((t,r)=>{const a=new FileReader;a.onloadend=()=>t(a.result),a.onerror=()=>r(),a.readAsDataURL(e)})}(e):URL.createObjectURL(e)}findPartByRelId(e,t=null){var r=(t.rels??this.rels).find(t=>t.id==e);const a=t?s(t.path)[0]:"";return r?this.partsMap[n(r.target,a)]:null}getPathById(e,t){const r=e.rels.find(e=>e.id==t),[a]=s(e.path);return r?n(r.target,a):null}}function we(e,t){const r=t.replace(/{|}|-/g,""),a=new Array(16);for(let e=0;e<16;e++)a[16-e-1]=parseInt(r.substring(2*e,2*e+2),16);for(let t=0;t<32;t++)e[t]=e[t]^a[t%16];return e}function Pe(e,t){return{type:B.BookmarkStart,id:t.attr(e,"id"),name:t.attr(e,"name"),colFirst:t.intAttr(e,"colFirst"),colLast:t.intAttr(e,"colLast")}}function Se(e,t){return{type:B.BookmarkEnd,id:t.attr(e,"id")}}class Me extends q{constructor(){super(...arguments),this.type=B.VmlElement,this.attrs={}}}function xe(e,t){var r=new Me;switch(e.localName){case"rect":r.tagName="rect",Object.assign(r.attrs,{width:"100%",height:"100%"});break;case"oval":r.tagName="ellipse",Object.assign(r.attrs,{cx:"50%",cy:"50%",rx:"50%",ry:"50%"});break;case"line":r.tagName="line";break;case"shape":r.tagName="g";break;case"textbox":r.tagName="foreignObject",Object.assign(r.attrs,{width:"100%",height:"100%"});break;default:return null}for(const t of w.attrs(e))switch(t.localName){case"style":r.cssStyleText=t.value;break;case"fillcolor":r.attrs.fill=t.value;break;case"from":const[e,a]=Te(t.value);Object.assign(r.attrs,{x1:e,y1:a});break;case"to":const[s,n]=Te(t.value);Object.assign(r.attrs,{x2:s,y2:n})}for(const a of w.elements(e))switch(a.localName){case"stroke":Object.assign(r.attrs,Ne(a));break;case"fill":Object.assign(r.attrs,Ce());break;case"imagedata":r.tagName="image",Object.assign(r.attrs,{width:"100%",height:"100%"}),r.imageHref={id:w.attr(a,"id"),title:w.attr(a,"title")};break;case"txbxContent":r.children.push(...t.parseBodyElements(a));break;default:const e=xe(a,t);e&&r.children.push(e)}return r}function Ne(e){return{stroke:w.attr(e,"color"),"stroke-width":w.lengthAttr(e,"weight",p)??"1px"}}function Ce(e){return{}}function Te(e){return e.split(",")}class Ae extends q{constructor(){super(...arguments),this.type=B.Comment}}class Ee extends q{constructor(e){super(),this.id=e,this.type=B.CommentReference}}class Re extends q{constructor(e){super(),this.id=e,this.type=B.CommentRangeStart}}class Be extends q{constructor(e){super(),this.id=e,this.type=B.CommentRangeEnd}}var De="inherit",Le="black",$e="black",Fe="transparent";const Ie=[],He={oMath:B.MmlMath,oMathPara:B.MmlMathParagraph,f:B.MmlFraction,func:B.MmlFunction,fName:B.MmlFunctionName,num:B.MmlNumerator,den:B.MmlDenominator,rad:B.MmlRadical,deg:B.MmlDegree,e:B.MmlBase,sSup:B.MmlSuperscript,sSub:B.MmlSubscript,sPre:B.MmlPreSubSuper,sup:B.MmlSuperArgument,sub:B.MmlSubArgument,d:B.MmlDelimiter,nary:B.MmlNary,eqArr:B.MmlEquationArray,lim:B.MmlLimit,limLow:B.MmlLimitLower,m:B.MmlMatrix,mr:B.MmlMatrixRow,box:B.MmlBox,bar:B.MmlBar,groupChr:B.MmlGroupChar};class Oe{constructor(e){this.options={ignoreWidth:!1,debug:!1,...e}}parseNotes(e,t,r){var a=[];for(let s of w.elements(e,t)){const e=new r;e.id=w.attr(s,"id"),e.noteType=w.attr(s,"type"),e.children=this.parseBodyElements(s),a.push(e)}return a}parseComments(e){var t=[];for(let r of w.elements(e,"comment")){const e=new Ae;e.id=w.attr(r,"id"),e.author=w.attr(r,"author"),e.initials=w.attr(r,"initials"),e.date=w.attr(r,"date"),e.children=this.parseBodyElements(r),t.push(e)}return t}parseDocumentFile(e){var t=w.element(e,"body"),r=w.element(e,"background"),a=w.element(t,"sectPr");return{type:B.Document,children:this.parseBodyElements(t),props:a?D(a,w):{},cssStyle:r?this.parseBackground(r):{}}}parseBackground(e){var t={},r=je.colorAttr(e,"color");return r&&(t["background-color"]=r),t}parseBodyElements(e){var t=[];for(const r of w.elements(e))switch(r.localName){case"p":t.push(this.parseParagraph(r));break;case"altChunk":t.push(this.parseAltChunk(r));break;case"tbl":t.push(this.parseTable(r));break;case"sdt":t.push(...this.parseSdt(r,e=>this.parseBodyElements(e)))}return t}parseStylesFile(e){var t=[];for(const r of w.elements(e))switch(r.localName){case"style":t.push(this.parseStyle(r));break;case"docDefaults":t.push(this.parseDefaultStyles(r))}return t}parseDefaultStyles(e){var t={id:null,name:null,target:null,basedOn:null,styles:[]};for(const s of w.elements(e))switch(s.localName){case"rPrDefault":var r=w.element(s,"rPr");r&&t.styles.push({target:"span",values:this.parseDefaultProperties(r,{})});break;case"pPrDefault":var a=w.element(s,"pPr");a&&t.styles.push({target:"p",values:this.parseDefaultProperties(a,{})})}return t}parseStyle(e){var t={id:w.attr(e,"styleId"),isDefault:w.boolAttr(e,"default"),name:null,target:null,basedOn:null,styles:[],linked:null};switch(w.attr(e,"type")){case"paragraph":t.target="p";break;case"table":t.target="table";break;case"character":t.target="span"}for(const r of w.elements(e))switch(r.localName){case"basedOn":t.basedOn=w.attr(r,"val");break;case"name":t.name=w.attr(r,"val");break;case"link":t.linked=w.attr(r,"val");break;case"next":t.next=w.attr(r,"val");break;case"aliases":t.aliases=w.attr(r,"val").split(",");break;case"pPr":t.styles.push({target:"p",values:this.parseDefaultProperties(r,{})}),t.paragraphProps=O(r,w);break;case"rPr":t.styles.push({target:"span",values:this.parseDefaultProperties(r,{})}),t.runProps=I(r,w);break;case"tblPr":case"tcPr":t.styles.push({target:"td",values:this.parseDefaultProperties(r,{})});break;case"tblStylePr":for(let e of this.parseTableStyle(r))t.styles.push(e);break;case"rsid":case"qFormat":case"hidden":case"semiHidden":case"unhideWhenUsed":case"autoRedefine":case"uiPriority":break;default:this.options.debug&&console.warn(`DOCX: Unknown style element: ${r.localName}`)}return t}parseTableStyle(e){var t=[],r="",a="";switch(w.attr(e,"type")){case"firstRow":a=".first-row",r="tr.first-row td";break;case"lastRow":a=".last-row",r="tr.last-row td";break;case"firstCol":a=".first-col",r="td.first-col";break;case"lastCol":a=".last-col",r="td.last-col";break;case"band1Vert":a=":not(.no-vband)",r="td.odd-col";break;case"band2Vert":a=":not(.no-vband)",r="td.even-col";break;case"band1Horz":a=":not(.no-hband)",r="tr.odd-row";break;case"band2Horz":a=":not(.no-hband)",r="tr.even-row";break;default:return[]}for(const s of w.elements(e))switch(s.localName){case"pPr":t.push({target:`${r} p`,mod:a,values:this.parseDefaultProperties(s,{})});break;case"rPr":t.push({target:`${r} span`,mod:a,values:this.parseDefaultProperties(s,{})});break;case"tblPr":case"tcPr":t.push({target:r,mod:a,values:this.parseDefaultProperties(s,{})})}return t}parseNumberingFile(e){const t=[],r=[],a=[];for(const s of w.elements(e))switch(s.localName){case"abstractNum":t.push(...this.parseAbstractNumbering(s,a));break;case"numPicBullet":a.push(this.parseNumberingPicBullet(s));break;case"num":r.push({numId:w.attr(s,"numId"),abstractNumId:w.elementAttr(s,"abstractNumId","val")})}return r.flatMap(e=>t.filter(t=>e.abstractNumId==t.id).map(t=>({...t,id:e.numId})))}parseNumberingPicBullet(e){var t=w.element(e,"pict"),r=t&&w.element(t,"shape"),a=r&&w.element(r,"imagedata");return a?{id:w.intAttr(e,"numPicBulletId"),src:w.attr(a,"id"),style:w.attr(r,"style")}:null}parseAbstractNumbering(e,t){var r=[],a=w.attr(e,"abstractNumId");for(const s of w.elements(e))if("lvl"===s.localName)r.push(this.parseNumberingLevel(a,s,t));return r}parseNumberingLevel(e,t,r){var a={id:e,level:w.intAttr(t,"ilvl"),start:1,pStyleName:void 0,pStyle:{},rStyle:{},suff:"tab"};for(const e of w.elements(t))switch(e.localName){case"start":a.start=w.intAttr(e,"val");break;case"pPr":this.parseDefaultProperties(e,a.pStyle);break;case"rPr":this.parseDefaultProperties(e,a.rStyle);break;case"lvlPicBulletId":var s=w.intAttr(e,"val");a.bullet=r.find(e=>e?.id==s);break;case"lvlText":a.levelText=w.attr(e,"val");break;case"pStyle":a.pStyleName=w.attr(e,"val");break;case"numFmt":a.format=w.attr(e,"val");break;case"suff":a.suff=w.attr(e,"val")}return a}parseSdt(e,t){const r=w.element(e,"sdtContent");return r?t(r):[]}parseChange(e,t,r){return{type:e,children:r(t)?.children??[],id:w.attr(t,"id"),author:w.attr(t,"author"),date:w.attr(t,"date")}}parseAltChunk(e){return{type:B.AltChunk,children:[],id:w.attr(e,"id")}}parseParagraph(e){var t={type:B.Paragraph,children:[]};for(let r of w.elements(e))switch(r.localName){case"pPr":this.parseParagraphProperties(r,t);break;case"r":t.children.push(this.parseRun(r,t));break;case"hyperlink":t.children.push(this.parseHyperlink(r,t));break;case"smartTag":t.children.push(this.parseSmartTag(r,t));break;case"bookmarkStart":t.children.push(Pe(r,w));break;case"bookmarkEnd":t.children.push(Se(r,w));break;case"commentRangeStart":t.children.push(new Re(w.attr(r,"id")));break;case"commentRangeEnd":t.children.push(new Be(w.attr(r,"id")));break;case"oMath":case"oMathPara":t.children.push(this.parseMathElement(r));break;case"sdt":t.children.push(...this.parseSdt(r,e=>this.parseParagraph(e).children));break;case"ins":t.children.push(this.parseChange(B.Inserted,r,e=>this.parseParagraph(e)));break;case"del":t.children.push(this.parseChange(B.Deleted,r,e=>this.parseParagraph(e)))}return t}parseParagraphProperties(e,t){this.parseDefaultProperties(e,t.cssStyle={},null,e=>{if(_(e,t,w))return!0;switch(e.localName){case"pStyle":t.styleName=w.attr(e,"val");break;case"cnfStyle":t.className=ze.classNameOfCnfStyle(e);break;case"framePr":this.parseFrame(e,t);break;case"rPr":break;default:return!1}return!0})}parseFrame(e,t){"drop"==w.attr(e,"dropCap")&&(t.cssStyle.float="left")}parseHyperlink(e,t){var r={type:B.Hyperlink,parent:t,children:[]};r.anchor=w.attr(e,"anchor"),r.id=w.attr(e,"id");for(const t of w.elements(e))if("r"===t.localName)r.children.push(this.parseRun(t,r));return r}parseSmartTag(e,t){var r={type:B.SmartTag,parent:t,children:[]},a=w.attr(e,"uri"),s=w.attr(e,"element");a&&(r.uri=a),s&&(r.element=s);for(const t of w.elements(e))switch(t.localName){case"r":r.children.push(this.parseRun(t,r));break;case"smartTag":r.children.push(this.parseSmartTag(t,r))}return r}parseRun(e,t){var r={type:B.Run,parent:t,children:[]};for(let t of w.elements(e))switch(t=this.checkAlternateContent(t),t.localName){case"t":r.children.push({type:B.Text,text:t.textContent});break;case"delText":r.children.push({type:B.DeletedText,text:t.textContent});break;case"commentReference":r.children.push(new Ee(w.attr(t,"id")));break;case"fldSimple":r.children.push({type:B.SimpleField,instruction:w.attr(t,"instr"),lock:w.boolAttr(t,"lock",!1),dirty:w.boolAttr(t,"dirty",!1)});break;case"instrText":r.fieldRun=!0,r.children.push({type:B.Instruction,text:t.textContent});break;case"fldChar":r.fieldRun=!0,r.children.push({type:B.ComplexField,charType:w.attr(t,"fldCharType"),lock:w.boolAttr(t,"lock",!1),dirty:w.boolAttr(t,"dirty",!1)});break;case"noBreakHyphen":r.children.push({type:B.NoBreakHyphen});break;case"br":r.children.push({type:B.Break,break:w.attr(t,"type")||"textWrapping"});break;case"lastRenderedPageBreak":r.children.push({type:B.Break,break:"lastRenderedPageBreak"});break;case"sym":r.children.push({type:B.Symbol,font:a(w.attr(t,"font")),char:w.hexAttr(t,"char")});break;case"tab":r.children.push({type:B.Tab});break;case"footnoteReference":r.children.push({type:B.FootnoteReference,id:w.attr(t,"id")});break;case"endnoteReference":r.children.push({type:B.EndnoteReference,id:w.attr(t,"id")});break;case"drawing":let e=this.parseDrawing(t);e&&r.children.push(e);break;case"pict":r.children.push(this.parseVmlPicture(t));break;case"rPr":this.parseRunProperties(t,r)}return r}parseMathElement(e){const t=`${e.localName}Pr`,r={type:He[e.localName],children:[]};for(const s of w.elements(e)){if(He[s.localName])r.children.push(this.parseMathElement(s));else if("r"==s.localName){var a=this.parseRun(s);a.type=B.MmlRun,r.children.push(a)}else s.localName==t&&(r.props=this.parseMathProperies(s))}return r}parseMathProperies(e){const t={};for(const r of w.elements(e))switch(r.localName){case"chr":t.char=w.attr(r,"val");break;case"vertJc":t.verticalJustification=w.attr(r,"val");break;case"pos":t.position=w.attr(r,"val");break;case"degHide":t.hideDegree=w.boolAttr(r,"val");break;case"begChr":t.beginChar=w.attr(r,"val");break;case"endChr":t.endChar=w.attr(r,"val")}return t}parseRunProperties(e,t){this.parseDefaultProperties(e,t.cssStyle={},null,e=>{switch(e.localName){case"rStyle":t.styleName=w.attr(e,"val");break;case"vertAlign":t.verticalAlign=ze.valueOfVertAlign(e,!0);break;default:return!1}return!0})}parseVmlPicture(e){const t={type:B.VmlPicture,children:[]};for(const r of w.elements(e)){const e=xe(r,this);e&&t.children.push(e)}return t}checkAlternateContent(e){if("AlternateContent"!=e.localName)return e;var t=w.element(e,"Choice");if(t){var r=w.attr(t,"Requires"),a=e.lookupNamespaceURI(r);if(Ie.includes(a))return t.firstElementChild}return w.element(e,"Fallback")?.firstElementChild}parseDrawing(e){for(var t of w.elements(e))switch(t.localName){case"inline":case"anchor":return this.parseDrawingWrapper(t)}}parseDrawingWrapper(e){var t={type:B.Drawing,children:[],cssStyle:{}},r="anchor"==e.localName;let a=null,s=w.boolAttr(e,"simplePos");w.boolAttr(e,"behindDoc");let n={relative:"page",align:"left",offset:"0"},l={relative:"page",align:"top",offset:"0"};for(var o of w.elements(e))switch(o.localName){case"simplePos":s&&(n.offset=w.lengthAttr(o,"x",p),l.offset=w.lengthAttr(o,"y",p));break;case"extent":t.cssStyle.width=w.lengthAttr(o,"cx",p),t.cssStyle.height=w.lengthAttr(o,"cy",p);break;case"positionH":case"positionV":if(!s){let e="positionH"==o.localName?n:l;var i=w.element(o,"align"),c=w.element(o,"posOffset");e.relative=w.attr(o,"relativeFrom")??e.relative,i&&(e.align=i.textContent),c&&(e.offset=y(c.textContent,p))}break;case"wrapTopAndBottom":a="wrapTopAndBottom";break;case"wrapNone":a="wrapNone";break;case"graphic":var h=this.parseGraphic(o);h&&t.children.push(h)}return"wrapTopAndBottom"==a?(t.cssStyle.display="block",n.align&&(t.cssStyle["text-align"]=n.align,t.cssStyle.width="100%")):"wrapNone"==a?(t.cssStyle.display="block",t.cssStyle.position="relative",t.cssStyle.width="0px",t.cssStyle.height="0px",n.offset&&(t.cssStyle.left=n.offset),l.offset&&(t.cssStyle.top=l.offset)):!r||"left"!=n.align&&"right"!=n.align||(t.cssStyle.float=n.align),t}parseGraphic(e){var t=w.element(e,"graphicData");for(let e of w.elements(t))if("pic"===e.localName)return this.parsePicture(e);return null}parsePicture(e){var t={type:B.Image,src:"",cssStyle:{}},r=w.element(e,"blipFill"),a=w.element(r,"blip"),s=w.element(r,"srcRect");t.src=w.attr(a,"embed"),s&&(t.srcRect=[w.intAttr(s,"l",0)/1e5,w.intAttr(s,"t",0)/1e5,w.intAttr(s,"r",0)/1e5,w.intAttr(s,"b",0)/1e5]);var n=w.element(e,"spPr"),l=w.element(n,"xfrm");if(t.cssStyle.position="relative",l)for(var o of(t.rotation=w.intAttr(l,"rot",0)/6e4,w.elements(l)))switch(o.localName){case"ext":t.cssStyle.width=w.lengthAttr(o,"cx",p),t.cssStyle.height=w.lengthAttr(o,"cy",p);break;case"off":t.cssStyle.left=w.lengthAttr(o,"x",p),t.cssStyle.top=w.lengthAttr(o,"y",p)}return t}parseTable(e){var t={type:B.Table,children:[]};for(const r of w.elements(e))switch(r.localName){case"tr":t.children.push(this.parseTableRow(r));break;case"tblGrid":t.columns=this.parseTableColumns(r);break;case"tblPr":this.parseTableProperties(r,t)}return t}parseTableColumns(e){var t=[];for(const r of w.elements(e))if("gridCol"===r.localName)t.push({width:w.lengthAttr(r,"w")});return t}parseTableProperties(e,t){switch(t.cssStyle={},t.cellStyle={},this.parseDefaultProperties(e,t.cssStyle,t.cellStyle,e=>{switch(e.localName){case"tblStyle":t.styleName=w.attr(e,"val");break;case"tblLook":t.className=ze.classNameOftblLook(e);break;case"tblpPr":this.parseTablePosition(e,t);break;case"tblStyleColBandSize":t.colBandSize=w.intAttr(e,"val");break;case"tblStyleRowBandSize":t.rowBandSize=w.intAttr(e,"val");break;case"hidden":t.cssStyle.display="none";break;default:return!1}return!0}),t.cssStyle["text-align"]){case"center":delete t.cssStyle["text-align"],t.cssStyle["margin-left"]="auto",t.cssStyle["margin-right"]="auto";break;case"right":delete t.cssStyle["text-align"],t.cssStyle["margin-left"]="auto"}}parseTablePosition(e,t){var r=w.lengthAttr(e,"topFromText"),a=w.lengthAttr(e,"bottomFromText"),s=w.lengthAttr(e,"rightFromText"),n=w.lengthAttr(e,"leftFromText");t.cssStyle.float="left",t.cssStyle["margin-bottom"]=ze.addSize(t.cssStyle["margin-bottom"],a),t.cssStyle["margin-left"]=ze.addSize(t.cssStyle["margin-left"],n),t.cssStyle["margin-right"]=ze.addSize(t.cssStyle["margin-right"],s),t.cssStyle["margin-top"]=ze.addSize(t.cssStyle["margin-top"],r)}parseTableRow(e){var t={type:B.Row,children:[]};for(const r of w.elements(e))switch(r.localName){case"tc":t.children.push(this.parseTableCell(r));break;case"trPr":case"tblPrEx":this.parseTableRowProperties(r,t)}return t}parseTableRowProperties(e,t){t.cssStyle=this.parseDefaultProperties(e,{},null,e=>{switch(e.localName){case"cnfStyle":t.className=ze.classNameOfCnfStyle(e);break;case"tblHeader":t.isHeader=w.boolAttr(e,"val");break;case"gridBefore":t.gridBefore=w.intAttr(e,"val");break;case"gridAfter":t.gridAfter=w.intAttr(e,"val");break;default:return!1}return!0})}parseTableCell(e){var t={type:B.Cell,children:[]};for(const r of w.elements(e))switch(r.localName){case"tbl":t.children.push(this.parseTable(r));break;case"p":t.children.push(this.parseParagraph(r));break;case"tcPr":this.parseTableCellProperties(r,t)}return t}parseTableCellProperties(e,t){t.cssStyle=this.parseDefaultProperties(e,{},null,e=>{switch(e.localName){case"gridSpan":t.span=w.intAttr(e,"val",null);break;case"vMerge":t.verticalMerge=w.attr(e,"val")??"continue";break;case"cnfStyle":t.className=ze.classNameOfCnfStyle(e);break;default:return!1}return!0}),this.parseTableCellVerticalText(e,t)}parseTableCellVerticalText(e,t){const r={btLr:{writingMode:"vertical-rl",transform:"rotate(180deg)"},lrTb:{writingMode:"vertical-lr",transform:"none"},tbRl:{writingMode:"vertical-rl",transform:"none"}};for(const a of w.elements(e))if("textDirection"===a.localName){const e=r[w.attr(a,"val")]||{writingMode:"horizontal-tb"};t.cssStyle["writing-mode"]=e.writingMode,t.cssStyle.transform=e.transform}}parseDefaultProperties(e,t=null,r=null,a=null){t=t||{};for(const s of w.elements(e))if(!a?.(s))switch(s.localName){case"jc":t["text-align"]=ze.valueOfJc(s);break;case"textAlignment":t["vertical-align"]=ze.valueOfTextAlignment(s);break;case"color":t.color=je.colorAttr(s,"val",null,Le);break;case"sz":t["font-size"]=t["min-height"]=w.lengthAttr(s,"val",d);break;case"shd":t["background-color"]=je.colorAttr(s,"fill",null,De);break;case"highlight":t["background-color"]=je.colorAttr(s,"val",null,Fe);break;case"vertAlign":break;case"position":t.verticalAlign=w.lengthAttr(s,"val",d);break;case"tcW":if(this.options.ignoreWidth)break;case"tblW":t.width=ze.valueOfSize(s,"w");break;case"trHeight":this.parseTrHeight(s,t);break;case"strike":t["text-decoration"]=w.boolAttr(s,"val",!0)?"line-through":"none";break;case"b":t["font-weight"]=w.boolAttr(s,"val",!0)?"bold":"normal";break;case"i":t["font-style"]=w.boolAttr(s,"val",!0)?"italic":"normal";break;case"caps":t["text-transform"]=w.boolAttr(s,"val",!0)?"uppercase":"none";break;case"smallCaps":t["font-variant"]=w.boolAttr(s,"val",!0)?"small-caps":"none";break;case"u":this.parseUnderline(s,t);break;case"ind":case"tblInd":this.parseIndentation(s,t);break;case"rFonts":this.parseFont(s,t);break;case"tblBorders":this.parseBorderProperties(s,r||t);break;case"tblCellSpacing":t["border-spacing"]=ze.valueOfMargin(s),t["border-collapse"]="separate";break;case"pBdr":this.parseBorderProperties(s,t);break;case"bdr":t.border=ze.valueOfBorder(s);break;case"tcBorders":this.parseBorderProperties(s,t);break;case"vanish":w.boolAttr(s,"val",!0)&&(t.display="none");break;case"kern":case"noWrap":break;case"tblCellMar":case"tcMar":this.parseMarginProperties(s,r||t);break;case"tblLayout":t["table-layout"]=ze.valueOfTblLayout(s);break;case"vAlign":t["vertical-align"]=ze.valueOfTextAlignment(s);break;case"spacing":"pPr"==e.localName&&this.parseSpacing(s,t);break;case"wordWrap":w.boolAttr(s,"val")&&(t["overflow-wrap"]="break-word");break;case"suppressAutoHyphens":t.hyphens=w.boolAttr(s,"val",!0)?"none":"auto";break;case"lang":t.$lang=w.attr(s,"val");break;case"rtl":case"bidi":w.boolAttr(s,"val",!0)&&(t.direction="rtl");break;case"bCs":case"iCs":case"szCs":case"tabs":case"outlineLvl":case"contextualSpacing":case"tblStyleColBandSize":case"tblStyleRowBandSize":case"webHidden":case"pageBreakBefore":case"suppressLineNumbers":case"keepLines":case"keepNext":case"widowControl":case"noProof":break;default:this.options.debug&&console.warn(`DOCX: Unknown document element: ${e.localName}.${s.localName}`)}return t}parseUnderline(e,t){var r=w.attr(e,"val");if(null!=r){switch(r){case"dash":case"dashDotDotHeavy":case"dashDotHeavy":case"dashedHeavy":case"dashLong":case"dashLongHeavy":case"dotDash":case"dotDotDash":t["text-decoration"]="underline dashed";break;case"dotted":case"dottedHeavy":t["text-decoration"]="underline dotted";break;case"double":t["text-decoration"]="underline double";break;case"single":case"thick":case"words":t["text-decoration"]="underline";break;case"wave":case"wavyDouble":case"wavyHeavy":t["text-decoration"]="underline wavy";break;case"none":t["text-decoration"]="none"}var a=je.colorAttr(e,"color");a&&(t["text-decoration-color"]=a)}}parseFont(e,t){var r=[w.attr(e,"ascii"),ze.themeValue(e,"asciiTheme"),w.attr(e,"eastAsia")].filter(e=>e).map(e=>a(e));r.length>0&&(t["font-family"]=[...new Set(r)].join(", "))}parseIndentation(e,t){var r=w.lengthAttr(e,"firstLine"),a=w.lengthAttr(e,"hanging"),s=w.lengthAttr(e,"left"),n=w.lengthAttr(e,"start"),l=w.lengthAttr(e,"right"),o=w.lengthAttr(e,"end");r&&(t["text-indent"]=r),a&&(t["text-indent"]=`-${a}`),(s||n)&&(t["margin-inline-start"]=s||n),(l||o)&&(t["margin-inline-end"]=l||o)}parseSpacing(e,t){var r=w.lengthAttr(e,"before"),a=w.lengthAttr(e,"after"),s=w.intAttr(e,"line",null),n=w.attr(e,"lineRule");if(r&&(t["margin-top"]=r),a&&(t["margin-bottom"]=a),null!==s)switch(n){case"auto":t["line-height"]=`${(s/240).toFixed(2)}`;break;case"atLeast":t["line-height"]=`calc(100% + ${s/20}pt)`;break;default:t["line-height"]=t["min-height"]=s/20+"pt"}}parseMarginProperties(e,t){for(const r of w.elements(e))switch(r.localName){case"left":t["padding-left"]=ze.valueOfMargin(r);break;case"right":t["padding-right"]=ze.valueOfMargin(r);break;case"top":t["padding-top"]=ze.valueOfMargin(r);break;case"bottom":t["padding-bottom"]=ze.valueOfMargin(r)}}parseTrHeight(e,t){w.attr(e,"hRule"),t.height=w.lengthAttr(e,"val")}parseBorderProperties(e,t){for(const r of w.elements(e))switch(r.localName){case"start":case"left":t["border-left"]=ze.valueOfBorder(r);break;case"end":case"right":t["border-right"]=ze.valueOfBorder(r);break;case"top":t["border-top"]=ze.valueOfBorder(r);break;case"bottom":t["border-bottom"]=ze.valueOfBorder(r)}}}const _e=["black","blue","cyan","darkBlue","darkCyan","darkGray","darkGreen","darkMagenta","darkRed","darkYellow","green","lightGray","magenta","none","red","white","yellow"];class je{static colorAttr(e,t,r=null,a="black"){var s=w.attr(e,t);if(s)return"auto"==s?a:_e.includes(s)?s:`#${s}`;var n=w.attr(e,"themeColor");return n?`var(--docx-${n}-color)`:r}}class ze{static themeValue(e,t){var r=w.attr(e,t);return r?`var(--docx-${r}-font)`:null}static valueOfSize(e,t){var r=u;switch(w.attr(e,"type")){case"dxa":break;case"pct":r=b;break;case"auto":return"auto"}return w.lengthAttr(e,t,r)}static valueOfMargin(e){return w.lengthAttr(e,"w")}static valueOfBorder(e){var t=ze.parseBorderType(w.attr(e,"val"));if("none"==t)return"none";var r=je.colorAttr(e,"color");return`${w.lengthAttr(e,"sz",g)} ${t} ${"auto"==r?$e:r}`}static parseBorderType(e){switch(e){case"single":case"dashDotStroked":case"thick":case"thickThinLargeGap":case"thickThinMediumGap":case"thickThinSmallGap":case"thinThickLargeGap":case"thinThickMediumGap":case"thinThickSmallGap":case"thinThickThinLargeGap":case"thinThickThinMediumGap":case"thinThickThinSmallGap":case"threeDEmboss":case"threeDEngrave":case"wave":return"solid";case"dashed":case"dashSmallGap":return"dashed";case"dotDash":case"dotDotDash":case"dotted":return"dotted";case"double":case"doubleWave":case"triple":return"double";case"inset":return"inset";case"nil":case"none":return"none";case"outset":return"outset"}return"solid"}static valueOfTblLayout(e){return"fixed"==w.attr(e,"val")?"fixed":"auto"}static classNameOfCnfStyle(e){const t=w.attr(e,"val"),r=["first-row","last-row","first-col","last-col","odd-col","even-col","odd-row","even-row","ne-cell","nw-cell","se-cell","sw-cell"];if(t)return r.filter((e,r)=>"1"==t[r]).join(" ");const a=["firstRow","lastRow","firstColumn","lastColumn","oddVBand","evenVBand","oddHBand","evenHBand","firstRowLastColumn","firstRowFirstColumn","lastRowLastColumn","lastRowFirstColumn"];return r.filter((t,r)=>w.boolAttr(e,a[r])).join(" ")}static valueOfJc(e){var t=w.attr(e,"val");switch(t){case"start":case"left":return"left";case"center":return"center";case"end":case"right":return"right";case"both":return"justify"}return t}static valueOfVertAlign(e,t=!1){var r=w.attr(e,"val");switch(r){case"subscript":return"sub";case"superscript":return t?"sup":"super"}return t?null:r}static valueOfTextAlignment(e){var t=w.attr(e,"val");switch(t){case"auto":case"baseline":return"baseline";case"top":return"top";case"center":return"middle";case"bottom":return"bottom"}return t}static addSize(e,t){return null==e?t:null==t?e:`calc(${e} + ${t})`}static classNameOftblLook(e){const t=w.hexAttr(e,"val",0);let r="";return(w.boolAttr(e,"firstRow")||32&t)&&(r+=" first-row"),(w.boolAttr(e,"lastRow")||64&t)&&(r+=" last-row"),(w.boolAttr(e,"firstColumn")||128&t)&&(r+=" first-col"),(w.boolAttr(e,"lastColumn")||256&t)&&(r+=" last-col"),(w.boolAttr(e,"noHBand")||512&t)&&(r+=" no-hband"),(w.boolAttr(e,"noVBand")||1024&t)&&(r+=" no-vband"),r.trim()}}const Ve={pos:0,leader:"none",style:"left"};function We(e,t,r,a=.75){const s=e.closest("p"),n=e.getBoundingClientRect(),l=s.getBoundingClientRect(),o=getComputedStyle(s),i=t?.length>0?t.map(e=>({pos:Xe(e.position),leader:e.leader,style:e.style})).sort((e,t)=>e.pos-t.pos):[Ve],c=i[i.length-1],h=l.width*a,m=Xe(r);let u=c.pos+m;if(u"clear"!=e.style&&e.pos>g);if(null==f)return;let b=1;if("right"==f.style||"center"==f.style){const t=Array.from(s.querySelectorAll(`.${e.className}`)),r=t.indexOf(e)+1,n=document.createRange();n.setStart(e,1),ro.appendChild(Ue(e))),o}!function(e){e.html="http://www.w3.org/1999/xhtml",e.svg="http://www.w3.org/2000/svg",e.mathML="http://www.w3.org/1998/Math/MathML"}(Ge||(Ge={}));class qe{constructor(){this.className="docx",this.styleMap={},this.currentPart=null,this.tableVerticalMerges=[],this.currentVerticalMerge=null,this.tableCellPositions=[],this.currentCellPosition=null,this.footnoteMap={},this.endnoteMap={},this.currentEndnoteIds=[],this.usedHederFooterParts=[],this.currentTabs=[],this.commentMap={},this.tasks=[],this.postRenderTasks=[],this.h=Ue}async render(e,t){this.document=e,this.options=t,this.className=t.className,this.rootSelector=t.inWrapper?`.${this.className}-wrapper`:":root",this.h=t.h??Ue,this.styleMap=null,this.tasks=[],this.options.renderComments&&globalThis.Highlight&&(this.commentHighlight=new Highlight);const r=[...this.renderDefaultStyle()];e.themePart&&r.push(...this.renderTheme(e.themePart)),null!=e.stylesPart&&(this.styleMap=this.processStyles(e.stylesPart.styles),r.push(...this.renderStyles(e.stylesPart.styles))),e.numberingPart&&(this.prodessNumberings(e.numberingPart.domNumberings),r.push(...await this.renderNumbering(e.numberingPart.domNumberings))),e.footnotesPart&&(this.footnoteMap=l(e.footnotesPart.notes,e=>e.id)),e.endnotesPart&&(this.endnoteMap=l(e.endnotesPart.notes,e=>e.id)),e.settingsPart&&(this.defaultTabSize=e.settingsPart.settings?.defaultTabStop),!t.ignoreFonts&&e.fontTablePart&&r.push(...await this.renderFontTable(e.fontTablePart));var a=this.renderSections(e.documentPart.body);return this.options.inWrapper?r.push(this.renderWrapper(a)):r.push(...a),this.commentHighlight&&t.renderComments&&CSS.highlights.set(`${this.className}-comments`,this.commentHighlight),this.postRenderTasks.forEach(e=>e()),await Promise.allSettled(this.tasks),this.refreshTabStops(),r}renderTheme(e){const t={},r=e.theme?.fontScheme;r&&(r.majorFont&&(t["--docx-majorHAnsi-font"]=r.majorFont.latinTypeface),r.minorFont&&(t["--docx-minorHAnsi-font"]=r.minorFont.latinTypeface));const a=e.theme?.colorScheme;if(a)for(let[e,r]of Object.entries(a.colors))t[`--docx-${e}-color`]=`#${r}`;const s=this.styleToString(`.${this.className}`,t);return[this.h({tagName:"#comment",children:["docxjs document theme values"]}),this.h({tagName:"style",children:[s]})]}async renderFontTable(e){const t=[];for(let r of e.fonts)for(let e of r.embedFontRefs)try{const s=await this.document.loadFont(e.id,e.key),n={"font-family":a(r.name),src:`url(${s})`};"bold"!=e.type&&"boldItalic"!=e.type||(n["font-weight"]="bold"),"italic"!=e.type&&"boldItalic"!=e.type||(n["font-style"]="italic"),t.push(this.h({tagName:"#comment",children:[`docxjs ${r.name} font`]})),t.push(this.h({tagName:"style",children:[this.styleToString("@font-face",n)]}))}catch(t){this.options.debug&&console.warn(`Can't load font with id ${e.id} and key ${e.key}`)}return t}processStyleName(e){return e?`${this.className}_${function(e){return e?.replace(/[ .]+/g,"-").replace(/[&]+/g,"and").toLowerCase()}(e)}`:this.className}processStyles(e){const t=l(e.filter(e=>null!=e.id),e=>e.id);for(const a of e.filter(e=>e.basedOn)){var r=t[a.basedOn];if(r){a.paragraphProps=c(a.paragraphProps,r.paragraphProps),a.runProps=c(a.runProps,r.runProps);for(const e of r.styles){const t=a.styles.find(t=>t.target==e.target);t?this.copyStyleProperties(e.values,t.values):a.styles.push({...e,values:{...e.values}})}}else this.options.debug&&console.warn(`Can't find base style ${a.basedOn}`)}for(let t of e)t.cssName=this.processStyleName(t.id);return t}prodessNumberings(e){for(let t of e.filter(e=>e.pStyleName)){const e=this.findStyle(t.pStyleName);e?.paragraphProps?.numbering&&(e.paragraphProps.numbering.level=t.level)}}processElement(e){if(e.children)for(var t of e.children)t.parent=e,t.type==B.Table?this.processTable(t):this.processElement(t)}processTable(e){for(var t of e.children)for(var r of t.children)r.cssStyle=this.copyStyleProperties(e.cellStyle,r.cssStyle,["border-left","border-right","border-top","border-bottom","padding-left","padding-right","padding-top","padding-bottom"]),this.processElement(r)}copyStyleProperties(e,t,r=null){if(!e)return t;for(var a of(null==t&&(t={}),null==r&&(r=Object.getOwnPropertyNames(e)),r))e.hasOwnProperty(a)&&!t.hasOwnProperty(a)&&(t[a]=e[a]);return t}createPageElement(e,t,r){const a={...r};return t&&(t.pageMargins&&(a.paddingLeft=t.pageMargins.left,a.paddingRight=t.pageMargins.right,a.paddingTop=t.pageMargins.top,a.paddingBottom=t.pageMargins.bottom),t.pageSize&&(this.options.ignoreWidth||(a.width=t.pageSize.width),this.options.ignoreHeight||(a.minHeight=t.pageSize.height))),this.h({tagName:"section",className:e,style:a})}createSectionContent(e){const t={};return e.columns&&e.columns.numberOfColumns&&(t.columnCount=`${e.columns.numberOfColumns}`,t.columnGap=e.columns.space,e.columns.separator&&(t.columnRule="1px solid black")),this.h({tagName:"article",style:t})}renderSections(e){const t=[];this.processElement(e);const r=this.splitBySection(e.children,e.props),a=this.groupByPageBreaks(r);let s=null;for(let r=0,l=a.length;r"first"==e.type):null)??(r%2==1?e.find(e=>"even"==e.type):null)??e.find(e=>"default"==e.type),l=n&&this.document.findPartByRelId(n.id,this.document.documentPart);if(l){this.currentPart=l,this.usedHederFooterParts.includes(l.path)||(this.processElement(l.rootElement),this.usedHederFooterParts.push(l.path));const[e]=this.renderElements([l.rootElement],s);t?.pageMargins&&(l.rootElement.type===B.Header?(e.style.marginTop=`calc(${t.pageMargins.header} - ${t.pageMargins.top})`,e.style.minHeight=`calc(${t.pageMargins.top} - ${t.pageMargins.header})`):l.rootElement.type===B.Footer&&(e.style.marginBottom=`calc(${t.pageMargins.footer} - ${t.pageMargins.bottom})`,e.style.minHeight=`calc(${t.pageMargins.bottom} - ${t.pageMargins.footer})`)),this.currentPart=null}}}isPageBreakElement(e){return e.type==B.Break&&("lastRenderedPageBreak"==e.break?!this.options.ignoreLastRenderedPageBreak:"page"==e.break)}isPageBreakSection(e,t){return!!e&&(!!t&&(e.pageSize?.orientation!=t.pageSize?.orientation||e.pageSize?.width!=t.pageSize?.width||e.pageSize?.height!=t.pageSize?.height))}splitBySection(e,t){var r={sectProps:null,elements:[],pageBreak:!1},a=[r];for(let t of e){if(t.type==B.Paragraph){const e=this.findStyle(t.styleName);e?.paragraphProps?.pageBreakBefore&&(r.sectProps=s,r.pageBreak=!0,r={sectProps:null,elements:[],pageBreak:!1},a.push(r))}if(r.elements.push(t),t.type==B.Paragraph){const e=t;var s=e.sectionProps,n=-1,l=-1;if(this.options.breakPages&&e.children&&(n=e.children.findIndex(e=>-1!=(l=e.children?.findIndex(this.isPageBreakElement.bind(this))??-1))),(s||-1!=n)&&(r.sectProps=s,r.pageBreak=-1!=n,r={sectProps:null,elements:[],pageBreak:!1},a.push(r)),-1!=n){let a=e.children[n],s=l=0;e--)null==a[e].sectProps?a[e].sectProps=c??t:c=a[e].sectProps;return a}groupByPageBreaks(e){let t,r=[];const a=[r];for(let s of e)r.push(s),(this.options.ignoreLastRenderedPageBreak||s.pageBreak||this.isPageBreakSection(t,s.sectProps))&&a.push(r=[]),t=s.sectProps;return a.filter(e=>e.length>0)}renderWrapper(e){return this.h({tagName:"div",className:`${this.className}-wrapper`,children:e})}renderDefaultStyle(){var e=this.className,t=`\n.${e}-wrapper { background: gray; padding: 30px; padding-bottom: 0px; display: flex; flex-flow: column; align-items: center; } \n.${e}-wrapper>section.${e} { background: white; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); margin-bottom: 30px; }`;this.options.hideWrapperOnPrint&&(t=`@media not print { ${t} }`);var r=`${t}\n.${e} { color: black; hyphens: auto; text-underline-position: from-font; }\nsection.${e} { box-sizing: border-box; display: flex; flex-flow: column nowrap; position: relative; overflow: hidden; }\nsection.${e}>article { margin-bottom: auto; z-index: 1; }\nsection.${e}>footer { z-index: 1; }\n.${e} table { border-collapse: collapse; }\n.${e} table td, .${e} table th { vertical-align: top; }\n.${e} p { margin: 0pt; min-height: 1em; }\n.${e} span { white-space: pre-wrap; overflow-wrap: break-word; }\n.${e} a { color: inherit; text-decoration: inherit; }\n.${e} svg { fill: transparent; }\n`;return this.options.renderComments&&(r+=`\n.${e}-comment-ref { cursor: default; }\n.${e}-comment-popover { display: none; z-index: 1000; padding: 0.5rem; background: white; position: absolute; box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.25); width: 30ch; }\n.${e}-comment-ref:hover~.${e}-comment-popover { display: block; }\n.${e}-comment-author,.${e}-comment-date { font-size: 0.875rem; color: #888; }\n`),[this.h({tagName:"#comment",children:["docxjs library predefined styles"]}),this.h({tagName:"style",children:[r]})]}async renderNumbering(e){var t="",r=[];for(var a of e){var s=`p.${this.numberingClass(a.id,a.level)}`,n="none";if(a.bullet){let e=`--${this.className}-${a.bullet.src}`.toLowerCase();t+=this.styleToString(`${s}:before`,{content:"' '",display:"inline-block",background:`var(${e})`},a.bullet.style);try{const r=await this.document.loadNumberingImage(a.bullet.src);t+=`${this.rootSelector} { ${e}: url(${r}) }`}catch(e){this.options.debug&&console.warn(`Can't load numbering image with src ${a.bullet.src}`)}}else if(a.levelText){let e=this.numberingCounter(a.id,a.level);const n=e+" "+(a.start-1);a.level>0&&(t+=this.styleToString(`p.${this.numberingClass(a.id,a.level-1)}`,{"counter-set":n})),r.push(n),t+=this.styleToString(`${s}:before`,{content:this.levelTextToContent(a.levelText,a.suff,a.id,this.numFormatToCssValue(a.format)),"counter-increment":e,...a.rStyle})}else n=this.numFormatToCssValue(a.format);t+=this.styleToString(s,{display:"list-item","list-style-position":"inside","list-style-type":n,...a.pStyle})}return r.length>0&&(t+=this.styleToString(this.rootSelector,{"counter-reset":r.join(" ")})),[this.h({tagName:"#comment",children:["docxjs document numbering styles"]}),this.h({tagName:"style",children:[t]})]}renderStyles(e){var t="";const r=this.styleMap,a=l(e.filter(e=>e.isDefault),e=>e.target);for(const l of e){var s=l.styles;if(l.linked){var n=l.linked&&r[l.linked];n?s=s.concat(n.styles):this.options.debug&&console.warn(`Can't find linked style ${l.linked}`)}for(const e of s){var o=`${l.target??""}.${l.cssName}`;l.target!=e.target&&(o+=` ${e.target}`),a[l.target]==l&&(o=`.${this.className} ${l.target}, `+o),t+=this.styleToString(o,e.values)}}return[this.h({tagName:"#comment",children:["docxjs document styles"]}),this.h({tagName:"style",children:[t]})]}renderNotes(e,t){var r=e.map(e=>t[e]).filter(e=>e);if(r.length>0)return this.h({tagName:"ol",children:this.renderElements(r)})}renderElement(e){switch(e.type){case B.Paragraph:return this.renderParagraph(e);case B.BookmarkStart:return this.renderBookmarkStart(e);case B.BookmarkEnd:return null;case B.Run:return this.renderRun(e);case B.Table:return this.renderTable(e);case B.Row:return this.renderTableRow(e);case B.Cell:return this.renderTableCell(e);case B.Hyperlink:return this.renderHyperlink(e);case B.SmartTag:return this.renderSmartTag(e);case B.Drawing:return this.renderDrawing(e);case B.Image:return this.renderImage(e);case B.Text:case B.Text:return this.renderText(e);case B.DeletedText:return this.renderDeletedText(e);case B.Tab:return this.renderTab(e);case B.Symbol:return this.renderSymbol(e);case B.Break:return this.renderBreak(e);case B.Footer:return this.renderContainer(e,"footer");case B.Header:return this.renderContainer(e,"header");case B.Footnote:case B.Endnote:return this.renderContainer(e,"li");case B.FootnoteReference:return this.renderFootnoteReference(e);case B.EndnoteReference:return this.renderEndnoteReference(e);case B.NoBreakHyphen:return this.h({tagName:"wbr"});case B.VmlPicture:return this.renderVmlPicture(e);case B.VmlElement:return this.renderVmlElement(e);case B.MmlMath:return this.renderContainerNS(e,Ge.mathML,"math",{xmlns:Ge.mathML});case B.MmlMathParagraph:return this.renderContainer(e,"span");case B.MmlFraction:return this.renderContainerNS(e,Ge.mathML,"mfrac");case B.MmlBase:return this.renderContainerNS(e,Ge.mathML,e.parent.type==B.MmlMatrixRow?"mtd":"mrow");case B.MmlNumerator:case B.MmlDenominator:case B.MmlFunction:case B.MmlLimit:case B.MmlBox:return this.renderContainerNS(e,Ge.mathML,"mrow");case B.MmlGroupChar:return this.renderMmlGroupChar(e);case B.MmlLimitLower:return this.renderContainerNS(e,Ge.mathML,"munder");case B.MmlMatrix:return this.renderContainerNS(e,Ge.mathML,"mtable");case B.MmlMatrixRow:return this.renderContainerNS(e,Ge.mathML,"mtr");case B.MmlRadical:return this.renderMmlRadical(e);case B.MmlSuperscript:return this.renderContainerNS(e,Ge.mathML,"msup");case B.MmlSubscript:return this.renderContainerNS(e,Ge.mathML,"msub");case B.MmlDegree:case B.MmlSuperArgument:case B.MmlSubArgument:return this.renderContainerNS(e,Ge.mathML,"mn");case B.MmlFunctionName:return this.renderContainerNS(e,Ge.mathML,"ms");case B.MmlDelimiter:return this.renderMmlDelimiter(e);case B.MmlRun:return this.renderMmlRun(e);case B.MmlNary:return this.renderMmlNary(e);case B.MmlPreSubSuper:return this.renderMmlPreSubSuper(e);case B.MmlBar:return this.renderMmlBar(e);case B.MmlEquationArray:return this.renderMllList(e);case B.Inserted:return this.renderInserted(e);case B.Deleted:return this.renderDeleted(e);case B.CommentRangeStart:return this.renderCommentRangeStart(e);case B.CommentRangeEnd:return this.renderCommentRangeEnd(e);case B.CommentReference:return this.renderCommentReference(e);case B.AltChunk:return this.renderAltChunk(e)}return null}renderElements(e,t){if(null==e)return null;var r=e.flatMap(e=>this.renderElement(e)).filter(e=>null!=e);return t&&r.forEach(e=>t.appendChild(i(e)?document.createTextNode(e):e)),r}renderContainer(e,t,r){return this.h({tagName:t,children:this.renderElements(e.children),...r})}renderContainerNS(e,t,r,a){return this.h({ns:t,tagName:r,children:this.renderElements(e.children),...a})}renderParagraph(e){var t=this.toHTML(e,Ge.html,"p");const r=this.findStyle(e.styleName);e.tabs??(e.tabs=r?.paragraphProps?.tabs);const a=e.numbering??r?.paragraphProps?.numbering;return a&&t.classList.add(this.numberingClass(a.id,a.level)),t}renderHyperlink(e){const t=this.toH(e,Ge.html,"a");if(t.href="",e.id){const r=this.document.documentPart.rels.find(t=>t.id==e.id&&"External"===t.targetMode);t.href=r?.target??t.href}return e.anchor&&(t.href+=`#${e.anchor}`),this.h(t)}renderSmartTag(e){return this.renderContainer(e,"span")}renderCommentRangeStart(e){if(!this.options.renderComments)return null;const t=new Range;this.commentHighlight?.add(t);const r=this.h({tagName:"#comment",children:[`start of comment #${e.id}`]});return this.later(()=>t.setStart(r,0)),this.commentMap[e.id]=t,r}renderCommentRangeEnd(e){if(!this.options.renderComments)return null;const t=this.commentMap[e.id],r=this.h({tagName:"#comment",children:[`end of comment #${e.id}`]});return this.later(()=>t?.setEnd(r,0)),r}renderCommentReference(e){if(!this.options.renderComments)return null;var t=this.document.commentsPart?.commentMap[e.id];if(!t)return null;const r=this.h({tagName:"span",className:`${this.className}-comment-ref`,children:["💬"]}),a=this.h({tagName:"div",className:`${this.className}-comment-popover`,children:[this.h({tagName:"div",className:`${this.className}-comment-author`,children:[t.author]}),this.h({tagName:"div",className:`${this.className}-comment-date`,children:[new Date(t.date).toLocaleString()]}),...this.renderElements(t.children)]});return this.h({tagName:"#fragment",children:[this.h({tagName:"#comment",children:[`comment #${t.id} by ${t.author} on ${t.date}`]}),r,a]})}renderAltChunk(e){if(!this.options.renderAltChunks)return null;var t=this.h({tagName:"iframe"});return this.tasks.push(this.document.loadAltChunk(e.id,this.currentPart).then(e=>{t.srcdoc=e})),t}renderDrawing(e){var t=this.toHTML(e,Ge.html,"div");return t.style.display="inline-block",t.style.position="relative",t.style.textIndent="0px",t}renderImage(e){let t=this.toHTML(e,Ge.html,"img",[]),r=e.cssStyle?.transform;if(e.srcRect&&e.srcRect.some(e=>0!=e)){var[a,s,n,l]=e.srcRect;r=`scale(${1/(1-a-n)}, ${1/(1-s-l)})`,t.style["clip-path"]=`rect(${(100*s).toFixed(2)}% ${(100*(1-n)).toFixed(2)}% ${(100*(1-l)).toFixed(2)}% ${(100*a).toFixed(2)}%)`}return e.rotation&&(r=`rotate(${e.rotation}deg) ${r??""}`),t.style.transform=r?.trim(),this.document&&this.tasks.push(this.document.loadDocumentImage(e.src,this.currentPart).then(e=>{t.src=e})),t}renderText(e){return this.h(e.text)}renderDeletedText(e){return this.options.renderChanges?this.renderText(e):null}renderBreak(e){return"textWrapping"==e.break?this.h({tagName:"br"}):null}renderInserted(e){return this.options.renderChanges?this.renderChange(e,"ins"):this.renderElements(e.children)}renderDeleted(e){return this.options.renderChanges?this.renderChange(e,"del"):null}renderChange(e,t){return this.renderContainer(e,t,{dateTime:e.date})}renderSymbol(e){return this.h({tagName:"span",children:[String.fromCharCode(e.char)],style:{fontFamily:e.font}})}renderFootnoteReference(e){return this.currentFootnoteIds.push(e.id),this.h({tagName:"sup",children:[`${this.currentFootnoteIds.length}`]})}renderEndnoteReference(e){return this.currentEndnoteIds.push(e.id),this.h({tagName:"sup",children:[`${this.currentEndnoteIds.length}`]})}renderTab(e){var t=this.h({tagName:"span",children:[" "]});if(this.options.experimental){t.className=this.tabStopClass();var r=function(e,t){var r=e.parent;for(;null!=r&&r.type!=t;)r=r.parent;return r}(e,B.Paragraph)?.tabs;this.currentTabs.push({stops:r,span:t})}return t}renderBookmarkStart(e){return this.h({tagName:"span",id:e.name})}renderRun(e){if(e.fieldRun)return null;let t=this.renderElements(e.children);e.verticalAlign&&(t=[this.h({tagName:e.verticalAlign,children:this.renderElements(e.children)})]);const r=this.toHTML(e,Ge.html,"span",t);return e.id&&(r.id=e.id),r}renderTable(e){this.tableCellPositions.push(this.currentCellPosition),this.tableVerticalMerges.push(this.currentVerticalMerge),this.currentVerticalMerge={},this.currentCellPosition={col:0,row:0};const t=[];return e.columns&&t.push(this.renderTableColumns(e.columns)),t.push(...this.renderElements(e.children)),this.currentVerticalMerge=this.tableVerticalMerges.pop(),this.currentCellPosition=this.tableCellPositions.pop(),this.toHTML(e,Ge.html,"table",t)}renderTableColumns(e){const t=e.map(e=>this.h({tagName:"col",style:{width:e.width}}));return this.h({tagName:"colgroup",children:t})}renderTableRow(e){this.currentCellPosition.col=0;const t=[];return e.gridBefore&&t.push(this.renderTableCellPlaceholder(e.gridBefore)),t.push(...this.renderElements(e.children)),e.gridAfter&&t.push(this.renderTableCellPlaceholder(e.gridAfter)),this.currentCellPosition.row++,this.toHTML(e,Ge.html,"tr",t)}renderTableCellPlaceholder(e){return this.h({tagName:"td",colSpan:e,style:{border:"none"}})}renderTableCell(e){let t=this.toHTML(e,Ge.html,"td");const r=this.currentCellPosition.col;return e.verticalMerge?"restart"==e.verticalMerge?(this.currentVerticalMerge[r]=t,t.rowSpan=1):this.currentVerticalMerge[r]&&(this.currentVerticalMerge[r].rowSpan+=1,t.style.display="none"):this.currentVerticalMerge[r]=null,e.span&&(t.colSpan=e.span),this.currentCellPosition.col+=t.colSpan,t}renderVmlPicture(e){return this.renderContainer(e,"div")}renderVmlElement(e){var t=this.h({ns:Ge.svg,tagName:"svg",style:e.cssStyleText});const r=this.renderVmlChildElement(e);return e.imageHref?.id&&this.tasks.push(this.document?.loadDocumentImage(e.imageHref.id,this.currentPart).then(e=>r.setAttribute("href",e))),t.appendChild(r),requestAnimationFrame(()=>{const e=t.firstElementChild.getBBox();t.setAttribute("width",`${Math.ceil(e.x+e.width)}`),t.setAttribute("height",`${Math.ceil(e.y+e.height)}`)}),t}renderVmlChildElement(e){const t=this.createSvgElement(e.tagName);Object.entries(e.attrs).forEach(([e,r])=>t.setAttribute(e,r));for(let r of e.children)r.type==B.VmlElement?t.appendChild(this.renderVmlChildElement(r)):t.appendChild(...h(this.renderElement(r)));return t}renderMmlRadical(e){const t=e.children.find(e=>e.type==B.MmlBase);if(e.props?.hideDegree)return this.createMathMLElement("msqrt",null,this.renderElements([t]));const r=e.children.find(e=>e.type==B.MmlDegree);return this.createMathMLElement("mroot",null,this.renderElements([t,r]))}renderMmlDelimiter(e){const t=[];return t.push(this.createMathMLElement("mo",null,[e.props.beginChar??"("])),t.push(...this.renderElements(e.children)),t.push(this.createMathMLElement("mo",null,[e.props.endChar??")"])),this.createMathMLElement("mrow",null,t)}renderMmlNary(e){const t=[],r=l(e.children,e=>e.type),a=r[B.MmlSuperArgument],s=r[B.MmlSubArgument],n=a?this.createMathMLElement("mo",null,h(this.renderElement(a))):null,o=s?this.createMathMLElement("mo",null,h(this.renderElement(s))):null,i=this.createMathMLElement("mo",null,[e.props?.char??"∫"]);return n||o?t.push(this.createMathMLElement("munderover",null,[i,o,n])):n?t.push(this.createMathMLElement("mover",null,[i,n])):o?t.push(this.createMathMLElement("munder",null,[i,o])):t.push(i),t.push(...this.renderElements(r[B.MmlBase].children)),this.createMathMLElement("mrow",null,t)}renderMmlPreSubSuper(e){const t=[],r=l(e.children,e=>e.type),a=r[B.MmlSuperArgument],s=r[B.MmlSubArgument],n=a?this.createMathMLElement("mo",null,h(this.renderElement(a))):null,o=s?this.createMathMLElement("mo",null,h(this.renderElement(s))):null,i=this.createMathMLElement("mo",null);return t.push(this.createMathMLElement("msubsup",null,[i,o,n])),t.push(...this.renderElements(r[B.MmlBase].children)),this.createMathMLElement("mrow",null,t)}renderMmlGroupChar(e){const t="bot"===e.props.verticalJustification?"mover":"munder",r=this.renderContainerNS(e,Ge.mathML,t);return e.props.char&&r.appendChild(this.createMathMLElement("mo",null,[e.props.char])),r}renderMmlBar(e){const t={};switch(e.props.position){case"top":t.textDecoration="overline";break;case"bottom":t.textDecoration="underline"}return this.renderContainerNS(e,Ge.mathML,"mrow",{style:t})}renderMmlRun(e){return this.toHTML(e,Ge.mathML,"ms")}renderMllList(e){const t=this.renderElements(e.children).map(e=>this.createMathMLElement("mtr",null,[this.createMathMLElement("mtd",null,[e])]));return this.toHTML(e,Ge.mathML,"mtable",t)}toH(e,t,r,a=null){const{$lang:s,...n}=e.cssStyle??{};return{ns:t,tagName:r,className:function(...e){return e.filter(Boolean).join(" ")}(e.className,e.styleName&&this.processStyleName(e.styleName)),lang:s,style:n,children:a??this.renderElements(e.children)}}toHTML(e,t,r,a=null){return this.h(this.toH(e,t,r,a))}findStyle(e){return e&&this.styleMap?.[e]}numberingClass(e,t){return`${this.className}-num-${e}-${t}`}tabStopClass(){return`${this.className}-tab-stop`}styleToString(e,t,r=null){let a=`${e} {\r\n`;for(const e in t)e.startsWith("$")||(a+=` ${e}: ${t[e]};\r\n`);return r&&(a+=r),a+"}\r\n"}numberingCounter(e,t){return`${this.className}-num-${e}-${t}`}levelTextToContent(e,t,r,a){return`"${e.replace(/%\d*/g,e=>{let t=parseInt(e.substring(1),10)-1;return`"counter(${this.numberingCounter(r,t)}, ${a})"`})}${{tab:"\\9",space:"\\a0"}[t]??""}"`}numFormatToCssValue(e){return{none:"none",bullet:"disc",decimal:"decimal",lowerLetter:"lower-alpha",upperLetter:"upper-alpha",lowerRoman:"lower-roman",upperRoman:"upper-roman",decimalZero:"decimal-leading-zero",aiueo:"katakana",aiueoFullWidth:"katakana",chineseCounting:"simp-chinese-informal",chineseCountingThousand:"simp-chinese-informal",chineseLegalSimplified:"simp-chinese-formal",chosung:"hangul-consonant",ideographDigital:"cjk-ideographic",ideographTraditional:"cjk-heavenly-stem",ideographLegalTraditional:"trad-chinese-formal",ideographZodiac:"cjk-earthly-branch",iroha:"katakana-iroha",irohaFullWidth:"katakana-iroha",japaneseCounting:"japanese-informal",japaneseDigitalTenThousand:"cjk-decimal",japaneseLegal:"japanese-formal",thaiNumbers:"thai",koreanCounting:"korean-hangul-formal",koreanDigital:"korean-hangul-formal",koreanDigital2:"korean-hanja-informal",hebrew1:"hebrew",hebrew2:"hebrew",hindiNumbers:"devanagari",ganada:"hangul",taiwaneseCounting:"cjk-ideographic",taiwaneseCountingThousand:"cjk-ideographic",taiwaneseDigital:"cjk-decimal"}[e]??e}refreshTabStops(){this.options.experimental&&setTimeout(()=>{const e=function(e=document.body){const t=document.createElement("div");t.style.width="100pt",e.appendChild(t);const r=100/t.offsetWidth;return e.removeChild(t),r}();for(let t of this.currentTabs)We(t.span,t.stops,this.defaultTabSize,e)},500)}createElementNS(e,t,r,a){return this.h({ns:e,tagName:t,children:a,...r})}createElement(e,t,r){return this.createElementNS(Ge.html,e,t,r)}createMathMLElement(e,t,r){return this.createElementNS(Ge.mathML,e,t,r)}createSvgElement(e,t,r){return this.createElementNS(Ge.svg,e,t,r)}later(e){this.postRenderTasks.push(e)}}const Je={ignoreHeight:!1,ignoreWidth:!1,ignoreFonts:!1,breakPages:!0,debug:!1,experimental:!1,className:"docx",inWrapper:!0,hideWrapperOnPrint:!1,trimXmlDeclaration:!0,ignoreLastRenderedPageBreak:!0,renderHeaders:!0,renderFooters:!0,renderFootnotes:!0,renderEndnotes:!0,useBase64URL:!1,renderChanges:!1,renderComments:!1,renderAltChunks:!0,h:Ue};function Ze(e,t){const r={...Je,...t};return ve.load(e,new Oe(r),r)}async function Ye(e,t){const r={...Je,...t},a=new qe;return await a.render(e,r)}e.defaultOptions=Je,e.parseAsync=Ze,e.renderAsync=async function(e,t,r,a){const s=await Ze(e,a),n=await Ye(s,a);r??(r=t),r.innerHTML="",t.innerHTML="";for(let e of n){("STYLE"===e.nodeName?r:t).appendChild(e)}return s},e.renderDocument=Ye}); +//# sourceMappingURL=docx-preview.min.js.map diff --git a/app/src/main/assets/docx/index.html b/app/src/main/assets/docx/index.html new file mode 100644 index 0000000..31e4733 --- /dev/null +++ b/app/src/main/assets/docx/index.html @@ -0,0 +1,70 @@ + + + + + + + + + + +
+ + + diff --git a/app/src/main/assets/docx/jszip.min.js b/app/src/main/assets/docx/jszip.min.js new file mode 100644 index 0000000..ff4cfd5 --- /dev/null +++ b/app/src/main/assets/docx/jszip.min.js @@ -0,0 +1,13 @@ +/*! + +JSZip v3.10.1 - A JavaScript class for generating and reading zip files + + +(c) 2009-2016 Stuart Knightley +Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. + +JSZip uses the library pako released under the MIT license : +https://github.com/nodeca/pako/blob/main/LICENSE +*/ + +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new h("Deflate",e)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function A(e,t){var r,n="";for(r=0;r>>=8;return n}function n(e,t,r,n,i,s){var a,o,h=e.file,u=e.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),c=I.transformTo("string",O.utf8encode(h.name)),d=h.comment,p=I.transformTo("string",s(d)),m=I.transformTo("string",O.utf8encode(d)),_=c.length!==h.name.length,g=m.length!==d.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===i?(C=798,z|=function(e,t){var r=e;return e||(r=t?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(e){return 63&(e||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+c,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\0",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\0\0\0\0"+A(z,4)+A(n,4)+f+b+p}}var I=e("../utils"),i=e("../stream/GenericWorker"),O=e("../utf8"),B=e("../crc32"),R=e("../signature");function s(e,t,r,n){i.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}I.inherits(s,i),s.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,i.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},s.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=n(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(e){this.accumulate=!1;var t=this.streamFiles&&!e.file.dir,r=n(e,t,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),t)this.push({data:function(e){return R.DATA_DESCRIPTOR+A(e.crc32,4)+A(e.compressedSize,4)+A(e.uncompressedSize,4)}(e),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),h=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new h(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(e,t){if(this._listeners[e])for(var r=0;r "+e:e}},t.exports=n},{}],29:[function(e,t,r){"use strict";var h=e("../utils"),i=e("./ConvertWorker"),s=e("./GenericWorker"),u=e("../base64"),n=e("../support"),a=e("../external"),o=null;if(n.nodestream)try{o=e("../nodejs/NodejsStreamOutputAdapter")}catch(e){}function l(e,o){return new a.Promise(function(t,r){var n=[],i=e._internalType,s=e._outputType,a=e._mimeType;e.on("data",function(e,t){n.push(e),o&&o(t)}).on("error",function(e){n=[],r(e)}).on("end",function(){try{var e=function(e,t,r){switch(e){case"blob":return h.newBlob(h.transformTo("arraybuffer",t),r);case"base64":return u.encode(t);default:return h.transformTo(e,t)}}(s,function(e,t){var r,n=0,i=null,s=0;for(r=0;r>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t}(e)},s.utf8decode=function(e){return h.nodebuffer?o.transformTo("nodebuffer",e).toString("utf-8"):function(e){var t,r,n,i,s=e.length,a=new Array(2*s);for(t=r=0;t>10&1023,a[r++]=56320|1023&n)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(e=o.transformTo(h.uint8array?"uint8array":"array",e))},o.inherits(a,n),a.prototype.processChunk=function(e){var t=o.transformTo(h.uint8array?"uint8array":"array",e.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=t;(t=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),t.set(r,this.leftOver.length)}else t=this.leftOver.concat(t);this.leftOver=null}var n=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}(t),i=t;n!==t.length&&(h.uint8array?(i=t.subarray(0,n),this.leftOver=t.subarray(n,t.length)):(i=t.slice(0,n),this.leftOver=t.slice(n,t.length))),this.push({data:s.utf8decode(i),meta:e.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,n),l.prototype.processChunk=function(e){this.push({data:s.utf8encode(e.data),meta:e.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(e,t,a){"use strict";var o=e("./support"),h=e("./base64"),r=e("./nodejsUtils"),u=e("./external");function n(e){return e}function l(e,t){for(var r=0;r>8;this.dir=!!(16&this.externalFileAttributes),0==e&&(this.dosPermissions=63&this.externalFileAttributes),3==e&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(){if(this.extraFields[1]){var e=n(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=e.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=e.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=e.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=e.readInt(4))}},readExtraFields:function(e){var t,r,n,i=e.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});e.index+4>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t},r.buf2binstring=function(e){return l(e,e.length)},r.binstring2buf=function(e){for(var t=new h.Buf8(e.length),r=0,n=t.length;r>10&1023,o[n++]=56320|1023&i)}return l(o,n)},r.utf8border=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}},{"./common":41}],43:[function(e,t,r){"use strict";t.exports=function(e,t,r,n){for(var i=65535&e|0,s=e>>>16&65535|0,a=0;0!==r;){for(r-=a=2e3>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}},{}],46:[function(e,t,r){"use strict";var h,c=e("../utils/common"),u=e("./trees"),d=e("./adler32"),p=e("./crc32"),n=e("./messages"),l=0,f=4,m=0,_=-2,g=-1,b=4,i=2,v=8,y=9,s=286,a=30,o=19,w=2*s+1,k=15,x=3,S=258,z=S+x+1,C=42,E=113,A=1,I=2,O=3,B=4;function R(e,t){return e.msg=n[t],t}function T(e){return(e<<1)-(4e.avail_out&&(r=e.avail_out),0!==r&&(c.arraySet(e.output,t.pending_buf,t.pending_out,r,e.next_out),e.next_out+=r,t.pending_out+=r,e.total_out+=r,e.avail_out-=r,t.pending-=r,0===t.pending&&(t.pending_out=0))}function N(e,t){u._tr_flush_block(e,0<=e.block_start?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,F(e.strm)}function U(e,t){e.pending_buf[e.pending++]=t}function P(e,t){e.pending_buf[e.pending++]=t>>>8&255,e.pending_buf[e.pending++]=255&t}function L(e,t){var r,n,i=e.max_chain_length,s=e.strstart,a=e.prev_length,o=e.nice_match,h=e.strstart>e.w_size-z?e.strstart-(e.w_size-z):0,u=e.window,l=e.w_mask,f=e.prev,c=e.strstart+S,d=u[s+a-1],p=u[s+a];e.prev_length>=e.good_match&&(i>>=2),o>e.lookahead&&(o=e.lookahead);do{if(u[(r=t)+a]===p&&u[r+a-1]===d&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do{}while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&sh&&0!=--i);return a<=e.lookahead?a:e.lookahead}function j(e){var t,r,n,i,s,a,o,h,u,l,f=e.w_size;do{if(i=e.window_size-e.lookahead-e.strstart,e.strstart>=f+(f-z)){for(c.arraySet(e.window,e.window,f,f,0),e.match_start-=f,e.strstart-=f,e.block_start-=f,t=r=e.hash_size;n=e.head[--t],e.head[t]=f<=n?n-f:0,--r;);for(t=r=f;n=e.prev[--t],e.prev[t]=f<=n?n-f:0,--r;);i+=f}if(0===e.strm.avail_in)break;if(a=e.strm,o=e.window,h=e.strstart+e.lookahead,u=i,l=void 0,l=a.avail_in,u=x)for(s=e.strstart-e.insert,e.ins_h=e.window[s],e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x)if(n=u._tr_tally(e,e.strstart-e.match_start,e.match_length-x),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=x){for(e.match_length--;e.strstart++,e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x&&e.match_length<=e.prev_length){for(i=e.strstart+e.lookahead-x,n=u._tr_tally(e,e.strstart-1-e.prev_match,e.prev_length-x),e.lookahead-=e.prev_length-1,e.prev_length-=2;++e.strstart<=i&&(e.ins_h=(e.ins_h<e.pending_buf_size-5&&(r=e.pending_buf_size-5);;){if(e.lookahead<=1){if(j(e),0===e.lookahead&&t===l)return A;if(0===e.lookahead)break}e.strstart+=e.lookahead,e.lookahead=0;var n=e.block_start+r;if((0===e.strstart||e.strstart>=n)&&(e.lookahead=e.strstart-n,e.strstart=n,N(e,!1),0===e.strm.avail_out))return A;if(e.strstart-e.block_start>=e.w_size-z&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):(e.strstart>e.block_start&&(N(e,!1),e.strm.avail_out),A)}),new M(4,4,8,4,Z),new M(4,5,16,8,Z),new M(4,6,32,32,Z),new M(4,4,16,16,W),new M(8,16,32,32,W),new M(8,16,128,128,W),new M(8,32,128,256,W),new M(32,128,258,1024,W),new M(32,258,258,4096,W)],r.deflateInit=function(e,t){return Y(e,t,v,15,8,0)},r.deflateInit2=Y,r.deflateReset=K,r.deflateResetKeep=G,r.deflateSetHeader=function(e,t){return e&&e.state?2!==e.state.wrap?_:(e.state.gzhead=t,m):_},r.deflate=function(e,t){var r,n,i,s;if(!e||!e.state||5>8&255),U(n,n.gzhead.time>>16&255),U(n,n.gzhead.time>>24&255),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(U(n,255&n.gzhead.extra.length),U(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(e.adler=p(e.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(U(n,0),U(n,0),U(n,0),U(n,0),U(n,0),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,3),n.status=E);else{var a=v+(n.w_bits-8<<4)<<8;a|=(2<=n.strategy||n.level<2?0:n.level<6?1:6===n.level?2:3)<<6,0!==n.strstart&&(a|=32),a+=31-a%31,n.status=E,P(n,a),0!==n.strstart&&(P(n,e.adler>>>16),P(n,65535&e.adler)),e.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending!==n.pending_buf_size));)U(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&&F(e),n.pending+2<=n.pending_buf_size&&(U(n,255&e.adler),U(n,e.adler>>8&255),e.adler=0,n.status=E)):n.status=E),0!==n.pending){if(F(e),0===e.avail_out)return n.last_flush=-1,m}else if(0===e.avail_in&&T(t)<=T(r)&&t!==f)return R(e,-5);if(666===n.status&&0!==e.avail_in)return R(e,-5);if(0!==e.avail_in||0!==n.lookahead||t!==l&&666!==n.status){var o=2===n.strategy?function(e,t){for(var r;;){if(0===e.lookahead&&(j(e),0===e.lookahead)){if(t===l)return A;break}if(e.match_length=0,r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++,r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):3===n.strategy?function(e,t){for(var r,n,i,s,a=e.window;;){if(e.lookahead<=S){if(j(e),e.lookahead<=S&&t===l)return A;if(0===e.lookahead)break}if(e.match_length=0,e.lookahead>=x&&0e.lookahead&&(e.match_length=e.lookahead)}if(e.match_length>=x?(r=u._tr_tally(e,1,e.match_length-x),e.lookahead-=e.match_length,e.strstart+=e.match_length,e.match_length=0):(r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++),r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):h[n.level].func(n,t);if(o!==O&&o!==B||(n.status=666),o===A||o===O)return 0===e.avail_out&&(n.last_flush=-1),m;if(o===I&&(1===t?u._tr_align(n):5!==t&&(u._tr_stored_block(n,0,0,!1),3===t&&(D(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),F(e),0===e.avail_out))return n.last_flush=-1,m}return t!==f?m:n.wrap<=0?1:(2===n.wrap?(U(n,255&e.adler),U(n,e.adler>>8&255),U(n,e.adler>>16&255),U(n,e.adler>>24&255),U(n,255&e.total_in),U(n,e.total_in>>8&255),U(n,e.total_in>>16&255),U(n,e.total_in>>24&255)):(P(n,e.adler>>>16),P(n,65535&e.adler)),F(e),0=r.w_size&&(0===s&&(D(r.head),r.strstart=0,r.block_start=0,r.insert=0),u=new c.Buf8(r.w_size),c.arraySet(u,t,l-r.w_size,r.w_size,0),t=u,l=r.w_size),a=e.avail_in,o=e.next_in,h=e.input,e.avail_in=l,e.next_in=0,e.input=t,j(r);r.lookahead>=x;){for(n=r.strstart,i=r.lookahead-(x-1);r.ins_h=(r.ins_h<>>=y=v>>>24,p-=y,0===(y=v>>>16&255))C[s++]=65535&v;else{if(!(16&y)){if(0==(64&y)){v=m[(65535&v)+(d&(1<>>=y,p-=y),p<15&&(d+=z[n++]<>>=y=v>>>24,p-=y,!(16&(y=v>>>16&255))){if(0==(64&y)){v=_[(65535&v)+(d&(1<>>=y,p-=y,(y=s-a)>3,d&=(1<<(p-=w<<3))-1,e.next_in=n,e.next_out=s,e.avail_in=n>>24&255)+(e>>>8&65280)+((65280&e)<<8)+((255&e)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(e){var t;return e&&e.state?(t=e.state,e.total_in=e.total_out=t.total=0,e.msg="",t.wrap&&(e.adler=1&t.wrap),t.mode=P,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new I.Buf32(n),t.distcode=t.distdyn=new I.Buf32(i),t.sane=1,t.back=-1,N):U}function o(e){var t;return e&&e.state?((t=e.state).wsize=0,t.whave=0,t.wnext=0,a(e)):U}function h(e,t){var r,n;return e&&e.state?(n=e.state,t<0?(r=0,t=-t):(r=1+(t>>4),t<48&&(t&=15)),t&&(t<8||15=s.wsize?(I.arraySet(s.window,t,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(n<(i=s.wsize-s.wnext)&&(i=n),I.arraySet(s.window,t,r-n,i,s.wnext),(n-=i)?(I.arraySet(s.window,t,r-n,n,0),s.wnext=n,s.whave=s.wsize):(s.wnext+=i,s.wnext===s.wsize&&(s.wnext=0),s.whave>>8&255,r.check=B(r.check,E,2,0),l=u=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&u)<<8)+(u>>8))%31){e.msg="incorrect header check",r.mode=30;break}if(8!=(15&u)){e.msg="unknown compression method",r.mode=30;break}if(l-=4,k=8+(15&(u>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){e.msg="invalid window size",r.mode=30;break}r.dmax=1<>8&1),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=3;case 3:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>8&255,E[2]=u>>>16&255,E[3]=u>>>24&255,r.check=B(r.check,E,4,0)),l=u=0,r.mode=4;case 4:for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>8),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=5;case 5:if(1024&r.flags){for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>>8&255,r.check=B(r.check,E,2,0)),l=u=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(d=r.length)&&(d=o),d&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,n,s,d,k)),512&r.flags&&(r.check=B(r.check,n,d,s)),o-=d,s+=d,r.length-=d),r.length))break e;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break e;for(d=0;k=n[s+d++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&d>9&1,r.head.done=!0),e.adler=r.check=0,r.mode=12;break;case 10:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>=7&l,l-=7&l,r.mode=27;break}for(;l<3;){if(0===o)break e;o--,u+=n[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==t)break;u>>>=2,l-=2;break e;case 2:r.mode=17;break;case 3:e.msg="invalid block type",r.mode=30}u>>>=2,l-=2;break;case 14:for(u>>>=7&l,l-=7&l;l<32;){if(0===o)break e;o--,u+=n[s++]<>>16^65535)){e.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&u,l=u=0,r.mode=15,6===t)break e;case 15:r.mode=16;case 16:if(d=r.length){if(o>>=5,l-=5,r.ndist=1+(31&u),u>>>=5,l-=5,r.ncode=4+(15&u),u>>>=4,l-=4,286>>=3,l-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=T(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=_,l-=_,r.lens[r.have++]=b;else{if(16===b){for(z=_+2;l>>=_,l-=_,0===r.have){e.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],d=3+(3&u),u>>>=2,l-=2}else if(17===b){for(z=_+3;l>>=_)),u>>>=3,l-=3}else{for(z=_+7;l>>=_)),u>>>=7,l-=7}if(r.have+d>r.nlen+r.ndist){e.msg="invalid bit length repeat",r.mode=30;break}for(;d--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){e.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=T(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=T(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){e.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===t)break e;case 20:r.mode=21;case 21:if(6<=o&&258<=h){e.next_out=a,e.avail_out=h,e.next_in=s,e.avail_in=o,r.hold=u,r.bits=l,R(e,c),a=e.next_out,i=e.output,h=e.avail_out,s=e.next_in,n=e.input,o=e.avail_in,u=r.hold,l=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(C=r.lencode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,r.length=b,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){e.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(C=r.distcode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,64&g){e.msg="invalid distance code",r.mode=30;break}r.offset=b,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){e.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===h)break e;if(d=c-h,r.offset>d){if((d=r.offset-d)>r.whave&&r.sane){e.msg="invalid distance too far back",r.mode=30;break}p=d>r.wnext?(d-=r.wnext,r.wsize-d):r.wnext-d,d>r.length&&(d=r.length),m=r.window}else m=i,p=a-r.offset,d=r.length;for(hd?(m=R[T+a[v]],A[I+a[v]]):(m=96,0),h=1<>S)+(u-=h)]=p<<24|m<<16|_|0,0!==u;);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,v++,0==--O[b]){if(b===w)break;b=t[r+a[v]]}if(k>>7)]}function U(e,t){e.pending_buf[e.pending++]=255&t,e.pending_buf[e.pending++]=t>>>8&255}function P(e,t,r){e.bi_valid>d-r?(e.bi_buf|=t<>d-e.bi_valid,e.bi_valid+=r-d):(e.bi_buf|=t<>>=1,r<<=1,0<--t;);return r>>>1}function Z(e,t,r){var n,i,s=new Array(g+1),a=0;for(n=1;n<=g;n++)s[n]=a=a+r[n-1]<<1;for(i=0;i<=t;i++){var o=e[2*i+1];0!==o&&(e[2*i]=j(s[o]++,o))}}function W(e){var t;for(t=0;t>1;1<=r;r--)G(e,s,r);for(i=h;r=e.heap[1],e.heap[1]=e.heap[e.heap_len--],G(e,s,1),n=e.heap[1],e.heap[--e.heap_max]=r,e.heap[--e.heap_max]=n,s[2*i]=s[2*r]+s[2*n],e.depth[i]=(e.depth[r]>=e.depth[n]?e.depth[r]:e.depth[n])+1,s[2*r+1]=s[2*n+1]=i,e.heap[1]=i++,G(e,s,1),2<=e.heap_len;);e.heap[--e.heap_max]=e.heap[1],function(e,t){var r,n,i,s,a,o,h=t.dyn_tree,u=t.max_code,l=t.stat_desc.static_tree,f=t.stat_desc.has_stree,c=t.stat_desc.extra_bits,d=t.stat_desc.extra_base,p=t.stat_desc.max_length,m=0;for(s=0;s<=g;s++)e.bl_count[s]=0;for(h[2*e.heap[e.heap_max]+1]=0,r=e.heap_max+1;r<_;r++)p<(s=h[2*h[2*(n=e.heap[r])+1]+1]+1)&&(s=p,m++),h[2*n+1]=s,u>=7;n>>=1)if(1&r&&0!==e.dyn_ltree[2*t])return o;if(0!==e.dyn_ltree[18]||0!==e.dyn_ltree[20]||0!==e.dyn_ltree[26])return h;for(t=32;t>>3,(s=e.static_len+3+7>>>3)<=i&&(i=s)):i=s=r+5,r+4<=i&&-1!==t?J(e,t,r,n):4===e.strategy||s===i?(P(e,2+(n?1:0),3),K(e,z,C)):(P(e,4+(n?1:0),3),function(e,t,r,n){var i;for(P(e,t-257,5),P(e,r-1,5),P(e,n-4,4),i=0;i>>8&255,e.pending_buf[e.d_buf+2*e.last_lit+1]=255&t,e.pending_buf[e.l_buf+e.last_lit]=255&r,e.last_lit++,0===t?e.dyn_ltree[2*r]++:(e.matches++,t--,e.dyn_ltree[2*(A[r]+u+1)]++,e.dyn_dtree[2*N(t)]++),e.last_lit===e.lit_bufsize-1},r._tr_align=function(e){P(e,2,3),L(e,m,z),function(e){16===e.bi_valid?(U(e,e.bi_buf),e.bi_buf=0,e.bi_valid=0):8<=e.bi_valid&&(e.pending_buf[e.pending++]=255&e.bi_buf,e.bi_buf>>=8,e.bi_valid-=8)}(e)}},{"../utils/common":41}],53:[function(e,t,r){"use strict";t.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(e,t,r){(function(e){!function(r,n){"use strict";if(!r.setImmediate){var i,s,t,a,o=1,h={},u=!1,l=r.document,e=Object.getPrototypeOf&&Object.getPrototypeOf(r);e=e&&e.setTimeout?e:r,i="[object process]"==={}.toString.call(r.process)?function(e){process.nextTick(function(){c(e)})}:function(){if(r.postMessage&&!r.importScripts){var e=!0,t=r.onmessage;return r.onmessage=function(){e=!1},r.postMessage("","*"),r.onmessage=t,e}}()?(a="setImmediate$"+Math.random()+"$",r.addEventListener?r.addEventListener("message",d,!1):r.attachEvent("onmessage",d),function(e){r.postMessage(a+e,"*")}):r.MessageChannel?((t=new MessageChannel).port1.onmessage=function(e){c(e.data)},function(e){t.port2.postMessage(e)}):l&&"onreadystatechange"in l.createElement("script")?(s=l.documentElement,function(e){var t=l.createElement("script");t.onreadystatechange=function(){c(e),t.onreadystatechange=null,s.removeChild(t),t=null},s.appendChild(t)}):function(e){setTimeout(c,0,e)},e.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),r=0;r 0L) { + runCatching { mirror(context, original, extensionHint, originalSize) } + return original + } + + val savedPath = prefs(context).getString(key(original), null) + val mirrored = savedPath?.let { File(it) } + if (mirrored != null && mirrored.exists() && mirrored.length() > 0L) { + return FileProvider.getUriForFile(context, "${context.packageName}.provider", mirrored) + } + return original + } + + /** Copies [original] into durable storage, skipping the copy if an up-to-date one already exists. */ + private fun mirror(context: Context, original: Uri, extensionHint: String, currentSize: Long) { + val cleanExt = extensionHint.trimStart('.').ifBlank { "pdf" } + val file = File(mirrorDir(context), "${key(original)}.$cleanExt") + + // A document that hasn't changed size since it was last mirrored is treated as unchanged. + // This mirror exists purely as an access-durability net, not a sync mechanism, so a cheap + // heuristic that avoids re-copying on every single open is the right trade-off. + if (file.exists() && file.length() == currentSize) { + prefs(context).edit().putString(key(original), file.absolutePath).apply() + return + } + + val tmp = File(file.parentFile, "${file.name}.tmp") + context.contentResolver.openInputStream(original)?.use { input -> + FileOutputStream(tmp).use { input.copyTo(it) } + } ?: return + + if (tmp.length() <= 0L) { + tmp.delete() + return + } + file.delete() + if (tmp.renameTo(file)) { + prefs(context).edit().putString(key(original), file.absolutePath).apply() + } else { + tmp.delete() + } + } + + /** Forgets and deletes the mirror for one URI — called when its Recents entry is removed. */ + fun forget(context: Context, original: Uri) { + val savedPath = prefs(context).getString(key(original), null) ?: return + runCatching { File(savedPath).delete() } + prefs(context).edit().remove(key(original)).apply() + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt index 3859eec..373e363 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt @@ -16,6 +16,8 @@ import com.kyant.pdfcore.viewer.PdfViewer import com.kyant.pdfcore.viewer.PdfViewerImpl import com.kyant.pdfcore.text.PdfTextService import com.kyant.pdfcore.text.PdfTextServiceImpl +import com.kyant.ocrcore.OcrService +import com.kyant.ocrcore.OcrServiceImpl object PdfServiceLocator { val pdfViewer: PdfViewer by lazy { PdfViewerImpl() } @@ -26,4 +28,6 @@ object PdfServiceLocator { val pdfEditor: PdfEditor by lazy { PdfEditorImpl() } val pdfConverter: PdfConverter by lazy { PdfConverterImpl() } val pdfTextService: PdfTextService by lazy { PdfTextServiceImpl() } + /** Fully on-device OCR — ML Kit primary, Tesseract4Android fallback. No network access. */ + val ocrService: OcrService by lazy { OcrServiceImpl() } } diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt index 015a5a1..bcdea57 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt @@ -69,10 +69,12 @@ object RecentFilesManager { } fun clearRecents(context: Context) { + getRecents(context).forEach { LocalDocumentMirror.forget(context, it.uri) } prefs(context).edit().remove(KEY_RECENTS).apply() } fun removeRecent(context: Context, uri: Uri) { + LocalDocumentMirror.forget(context, uri) val remaining = getRecents(context).filterNot { it.uriString == uri.toString() } prefs(context).edit().apply { if (remaining.isEmpty()) { diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt index e3c24ae..a281b32 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt @@ -132,9 +132,15 @@ fun GlassSearchHeader( // The bounce rides its own clock so it can overshoot without dragging `progress` — and therefore // the layout width — past 1. Everything it drives is a draw-time property, so the overshoot costs // a layer-matrix update, not a re-measure: `drawBackdrop` never re-runs its blur or lens. - // Open only. Overshooting on close drives the scale under its floor and reads as a glitch. + // Symmetric on purpose: this used to spring open with `morph()` but close with the fully + // rigid `settle()` (no overshoot at all), on the theory that overshooting past the field's + // floor scale on the way in would look broken. It doesn't — every use of `bounce` below either + // clamps to [0,1] (the pill's shrink) or tolerates a few percent past its floor for a couple of + // frames (the field's grow-in, the icon's spin) — so there was nothing actually protecting + // against, just an animation that sprang open and then went dead on the way back, closing every + // single time compared with opening. val bounce by transition.animateFloat( - transitionSpec = { if (targetState) GlassMotion.morph() else GlassMotion.settle() }, + transitionSpec = { GlassMotion.morph() }, label = "searchBounce" ) { if (it) 1f else 0f } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt index da2ec5f..e863bde 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt @@ -300,7 +300,12 @@ fun DemoDocumentOpen(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: C scaleX = t } .clip(RoundedCornerShape(3.dp)) - .background(lineTint.copy(if (idx == 0) 0.85f else 0.62f)) + // Was 0.85 for the first line and a flat 0.62 for every line after it — + // a steep drop that read as "the first sheet landed bright, the rest are + // washed out," which is exactly backwards for a book meant to show all + // six formats having equally assembled it. Close enough now that the + // first line still reads as a heading without the rest going dim. + .background(lineTint.copy(if (idx == 0) 0.90f else 0.80f)) ) } } @@ -401,6 +406,12 @@ fun DemoFileKinds(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: Colo label = "fileKind" ) { i -> val k = cards[i] + // The badge pops on every turnover instead of just cross-fading flat — a small overshoot + // that lands, so "now it's a Word doc" reads as an arrival rather than a still image being + // swapped out from under itself. + val badgePop = remember(i) { Animatable(0.72f) } + LaunchedEffect(i) { badgePop.animateTo(1f, GlassMotion.pop()) } + Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -409,6 +420,7 @@ fun DemoFileKinds(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: Colo Box( Modifier .size(76.dp) + .graphicsLayer { scaleX = badgePop.value; scaleY = badgePop.value } .clip(RoundedCornerShape(22.dp)) .background(k.tint.copy(0.92f)), contentAlignment = Alignment.Center @@ -449,6 +461,28 @@ fun DemoFileKinds(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: Colo } } } + + // Which format is cycling now, out of how many — without this the card just silently + // relabels itself every 1.5s with no sense of a sequence being shown. Same pill-dot language + // as the pager's own [PageDots], tinted to the active format instead of a flat ink colour. + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + cards.forEachIndexed { i, k -> + val active = i == index + val w by animateFloatAsState(if (active) 16f else 5f, GlassMotion.settle(), label = "kindDotW$i") + val dotColor by animateColorAsState( + if (active) k.tint.copy(0.9f) else ink.copy(0.22f), + tween(320), + label = "kindDotColor$i" + ) + Box( + Modifier + .width(w.dp) + .height(5.dp) + .clip(RoundedCornerShape(50)) + .background(dotColor) + ) + } + } } } @@ -618,7 +652,14 @@ fun DemoToolsMenu( // ── Page 5 · Ready ────────────────────────────────────────────────────────────────────────────── /** - * A glass disc springs in, then a checkmark draws itself inside it. + * A glass disc springs in, then a checkmark draws itself inside it, ringed by the six format dots + * that opened the tour on page 1. + * + * A checkmark alone in a circle is the one generic "success" cliché every templated app onboarding + * reaches for, with nothing about it specific to this app. The ring ties it back to the actual + * promise being confirmed — closing the loop the flow opened with the same six colours from + * [DemoKinds]/page 1's flying sheets: "that's all six formats, sorted." It reuses the format's own + * language rather than inventing a new decorative element. * * Same [PathMeasure] trim as [DemoAnnotate] — the tick is *drawn*, not faded in, which is what makes * it read as a confirmation rather than an icon appearing. Plays once per visit rather than looping: @@ -639,8 +680,40 @@ fun DemoReady(isActive: Boolean, backdrop: Backdrop, glass: Color) { tween(520, delayMillis = 180, easing = FastOutSlowInEasing), label = "readyTick" ) + // A settle, not the disc's own bouncy morph() — six dots overshooting independently around a + // ring reads as jitter, not as a landing. + val ring by animateFloatAsState( + if (play == 1) 1f else 0f, + tween(640, delayMillis = 120, easing = FastOutSlowInEasing), + label = "readyRing" + ) - Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) { + Box(Modifier.size(176.dp), contentAlignment = Alignment.Center) { + // Six small dots settling into a ring around the disc — each on its own staggered slice of + // `ring`, same head-offset idiom page 1 uses for its book's lines, so an early dot is + // fully landed while a later one is still arriving. + DemoKinds.forEachIndexed { i, kind -> + val angle = -Pi / 2f + (i.toFloat() / DemoKinds.size) * TwoPi + val head = i * 0.08f + val t = ((ring - head) / (1f - head)).coerceIn(0f, 1f) + val eased = smooth(t) + Box( + Modifier + .size(12.dp) + .graphicsLayer { + // Drifts in from a touch further out, so it reads as arriving into the ring + // rather than just fading up in place. + val radius = (72f - 10f * (1f - eased)) * density + translationX = cos(angle) * radius + translationY = sin(angle) * radius + val s = lerp(0.4f, 1f, eased) + scaleX = s; scaleY = s + alpha = eased + } + .clip(CircleShape) + .background(kind.tint) + ) + } // A soft accent halo behind the glass, flat, so it can pulse without costing a re-blur. Box( Modifier diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt b/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt index 3d1a6dc..05f5739 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt @@ -656,7 +656,11 @@ fun DocsNavGraph( factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - ExtractTextViewModel(PdfServiceLocator.pdfConverter) as T + ExtractTextViewModel( + PdfServiceLocator.pdfConverter, + PdfServiceLocator.ocrService, + PdfServiceLocator.pdfViewer + ) as T } ) ExtractTextScreen( diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt index d3cfa87..4692e45 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt @@ -163,11 +163,53 @@ fun ExtractTextScreen( } state.errorMessage?.let { BasicText(it, style = TextStyle(Color(0xFFFFB300), 12.sp)) } + if (state.canRecognize && !state.isRecognizing) { + LiquidButton( + onClick = { viewModel.recognizeText(context) }, + backdrop = backdrop, + tint = accent, + modifier = Modifier.fillMaxWidth() + ) { + BasicText(stringResource(R.string.extract_recognize_text), style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) + } + } + if (state.canMakeSearchable) { + LiquidButton( + onClick = { viewModel.makeSearchablePdf(context) }, + backdrop = backdrop, + tint = Color(0xFF2E7D32), + modifier = Modifier.fillMaxWidth() + ) { + if (state.isSavingSearchable) { + CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + } else { + BasicText(stringResource(R.string.extract_make_searchable), style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) + } + } + } + if (state.searchableOutputUri != null) { + BasicText( + stringResource(R.string.extract_searchable_saved, state.searchableSavedLabel.orEmpty()), + style = TextStyle(Color(0xFF2E7D32), 12.sp) + ) + } + Box(Modifier.fillMaxWidth().weight(1f).liquidGlassPanel(backdrop, uiSensor).padding(14.dp)) { when { state.isExtracting -> Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator(color = accent, strokeWidth = 2.dp) } + state.isRecognizing -> Box(Modifier.fillMaxSize(), Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + CircularProgressIndicator(color = accent, strokeWidth = 2.dp) + val progress = state.recognizeProgress + BasicText( + if (progress != null) stringResource(R.string.extract_recognizing, progress.first + 1, progress.second) + else stringResource(R.string.extract_recognize_text), + style = TextStyle(sub, 12.sp) + ) + } + } state.text.isNotEmpty() -> SelectionContainer(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { BasicText(state.text, style = TextStyle(text, 14.sp)) } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt index 8735463..8f86c6f 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt @@ -428,7 +428,11 @@ private fun LanguageChooser( stringResource(R.string.onboarding_language_label), style = TextStyle(inkSoft, 13.sp, fontWeight = FontWeight.SemiBold) ) - listOf("en" to R.string.language_english, "pt-BR" to R.string.language_portuguese).forEach { (code, res) -> + listOf( + "en" to R.string.language_english, + "pt-BR" to R.string.language_portuguese, + "es" to R.string.language_spanish + ).forEach { (code, res) -> val selected = selectedLocale == code LiquidButton( onClick = { onLocaleSelected(code) }, diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt index a3900a3..b614df9 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.aspectRatio @@ -23,9 +25,19 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.FormatUnderlined +import androidx.compose.material.icons.rounded.Highlight +import androidx.compose.material.icons.rounded.SelectAll +import androidx.compose.material.icons.rounded.StrikethroughS import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,6 +59,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -65,6 +78,22 @@ import com.chethan616.clearpdf.ui.viewmodel.OcrTextRange import kotlin.math.max import kotlin.math.min +/** One icon+label action in the stock-style text-selection menu (Acrobat/Drive look). */ +@Composable +private fun SelectionMenuItem(icon: ImageVector, label: String, tint: Color, onClick: () -> Unit) { + Column( + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp) + .width(52.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(icon, label, Modifier.size(20.dp), tint) + BasicText(label, style = TextStyle(tint, 10.sp, FontWeight.Medium), maxLines = 1) + } +} + /** * A single page inside the continuous (Adobe-style) vertical viewer. * @@ -109,6 +138,9 @@ internal fun PdfContinuousPage( onDeleteMarkup: (Int) -> Unit = {}, onCopySelection: () -> Unit = {}, onHighlightSelection: () -> Unit = {}, + onUnderlineSelection: () -> Unit = {}, + onStrikeSelection: () -> Unit = {}, + onSetColorLong: (Long) -> Unit = {}, onSelectAll: () -> Unit = {} ) { var draftPoints by remember(page, activeTool) { mutableStateOf>(emptyList()) } @@ -128,11 +160,32 @@ internal fun PdfContinuousPage( // is no forced-aspect placeholder jump, and single / landscape pages lay out // correctly. Every overlay uses matchParentSize() so its coordinate frame is // exactly the image frame (0,0 → box size). + // Height to reserve while this page has no bitmap — either it has not rendered yet, or it + // rendered once and was evicted from the cache while staying composed. + // + // This used to be hardcoded to A4 portrait. On a landscape document (a converted .pptx is 16:9) + // that reserved a box ~2.5x taller than the page, and the box collapsed the instant the bitmap + // arrived. On the last page that collapse is a feedback loop: the list is centred + // (`Arrangement.Center`), so the shrink shifts content, which can push the page out of the + // viewport, which disposes it, which restores the tall placeholder, which shifts it back in — + // visible as the last page flickering. Reserving the page's real aspect removes the size change + // entirely, so there is nothing left to oscillate. + // + // `pageBitmapSizes` survives eviction (it is only cleared when the document changes), so a page + // that has ever rendered knows its own shape; anything else borrows the first page that does, + // since documents are near enough uniform. Only the very first page of a fresh document falls + // through to the A4 guess. + val placeholderAspect = if (bitmap != null) null else { + (pageBitmapSizes[page] ?: pageBitmapSizes.values.firstOrNull { it.width > 0f && it.height > 0f }) + ?.takeIf { it.width > 0f && it.height > 0f } + ?.let { it.width / it.height } + } + Box( Modifier .fillMaxWidth() .padding(vertical = 6.dp) - .then(if (bitmap == null) Modifier.aspectRatio(1f / 1.414f) else Modifier) + .then(if (bitmap == null) Modifier.aspectRatio(placeholderAspect ?: (1f / 1.414f)) else Modifier) .background(Color(0xFF15181E)) // Native platform loupe (Android 9+). Inactive — and a no-op on older devices — // whenever the focus point is Unspecified, so it costs nothing outside a drag. @@ -170,10 +223,10 @@ internal fun PdfContinuousPage( selectedOcrRanges.forEach { range -> ocrBlocks.firstOrNull { it.id == range.blockId }?.let { b -> val r = expandedTextHighlightRect(ocrTextRangeToRect(b, range, frame), verticalScale = 1.35f) - // Match the reference's soft cyan fill: no border, no font changes, and - // enough vertical air for ascenders/descenders without covering other lines. + // Stock-Android text-selection look: a subtle translucent band in the same + // blue as the selection handles (#4285F4), not an opaque saturated cyan block. val radius = (r.height * 0.14f).coerceIn(2f, 5f) - drawRoundRect(Color(0xFF9ADBF0).copy(0.82f), r.topLeft, r.size, CornerRadius(radius, radius)) + drawRoundRect(Color(0xFF4285F4).copy(0.28f), r.topLeft, r.size, CornerRadius(radius, radius)) } } @@ -312,8 +365,16 @@ internal fun PdfContinuousPage( if (activeTool == PdfEditTool.SelectText) { ocrSelectionHandleAnchors(ocrBlocks, selectedOcrRanges, frame)?.let { (start, end) -> - drawTextSelectionHandle(start, selectionHandleDiameterPx) - drawTextSelectionHandle(end, selectionHandleDiameterPx) + // Size each handle off its own line's rendered height, clamped to a sane + // touch-target range, instead of a fixed dp that ignores the page's current + // zoom/fit-width scale (see ocrSelectionHandleLineHeights doc). + val (startLineH, endLineH) = ocrSelectionHandleLineHeights(ocrBlocks, selectedOcrRanges, frame) + ?: (selectionHandleDiameterPx to selectionHandleDiameterPx) + // DrawScope is itself a Density, so dp -> px works directly here. + val minPx = 14.dp.toPx() + val maxPx = 30.dp.toPx() + drawTextSelectionHandle(start, (startLineH * 1.1f).coerceIn(minPx, maxPx)) + drawTextSelectionHandle(end, (endLineH * 1.1f).coerceIn(minPx, maxPx)) } } @@ -675,63 +736,74 @@ internal fun PdfContinuousPage( marks.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == id } } val gapPx = with(density) { 8.dp.toPx() } - val bubbleHpx = with(density) { 44.dp.toPx() } - val bubbleWpx = with(density) { (if (selectionHasHighlight) 372.dp else 296.dp).toPx() } + val bubbleHpx = with(density) { 148.dp.toPx() } + val itemCount = if (selectionHasHighlight) 6 else 5 + val bubbleWpx = with(density) { (itemCount * 60).dp.toPx() } // Flip below the selection when it's too close to the page top. val placeBelow = selTop < bubbleHpx + gapPx val by = (if (placeBelow) selBottom + gapPx else selTop - bubbleHpx - gapPx).coerceIn(0f, (csz.height - bubbleHpx).coerceAtLeast(0f)) val bx = ((selLeft + selRight) / 2f - bubbleWpx / 2f).coerceIn(0f, (csz.width - bubbleWpx).coerceAtLeast(0f)) - Row( + // Icon-menu look (Acrobat/Drive-style), not a pill of coloured text: a plain + // dark card, one icon+label column per action, neutral colour throughout except + // the active/applied state and the destructive action. + val menuFg = Color(0xFFCCCCCC) + Column( Modifier .offset { IntOffset(bx.roundToInt(), by.roundToInt()) } - .clip(RoundedCornerShape(22.dp)) - .background(Color(0xFF1C1F26).copy(0.97f)) - .border(1.dp, Color.White.copy(0.14f), RoundedCornerShape(22.dp)) - .padding(horizontal = 4.dp, vertical = 3.dp), - verticalAlignment = Alignment.CenterVertically + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFF232629)) + .border(1.dp, Color.White.copy(0.08f), RoundedCornerShape(14.dp)) ) { - BasicText( - "Copy", - style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold), - modifier = Modifier - .clip(RoundedCornerShape(18.dp)) - .clickable { onCopySelection(); onClearOcrSelection() } - .padding(horizontal = 16.dp, vertical = 9.dp) - ) - Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) - BasicText( - "Select all", - style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold), - modifier = Modifier - .clip(RoundedCornerShape(18.dp)) - .clickable { onSelectAll() } - .padding(horizontal = 16.dp, vertical = 9.dp) - ) - Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) - BasicText( - if (selectionHasHighlight) "Recolor" else "Highlight", - style = TextStyle(Color(0xFFFFCC33), 13.sp, FontWeight.SemiBold), - modifier = Modifier - .clip(RoundedCornerShape(18.dp)) - .clickable { onHighlightSelection() } - .padding(horizontal = 16.dp, vertical = 9.dp) - ) - if (selectionHasHighlight) { - Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) - BasicText( - "Delete", - style = TextStyle(Color(0xFFFF6B6B), 13.sp, FontWeight.SemiBold), - modifier = Modifier - .clip(RoundedCornerShape(18.dp)) - .clickable { - // Drop every highlight sitting on a selected word, then clear the - // selection so the pill dismisses. - marks.removeAll { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId in selectedOcrIds } - onClearOcrSelection() - } - .padding(horizontal = 16.dp, vertical = 9.dp) - ) + Row( + Modifier + .padding(horizontal = 4.dp, vertical = 6.dp) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically + ) { + SelectionMenuItem(Icons.Rounded.ContentCopy, "Copy", menuFg) { + onCopySelection(); onClearOcrSelection() + } + SelectionMenuItem(Icons.Rounded.SelectAll, "Select all", menuFg) { onSelectAll() } + SelectionMenuItem( + Icons.Rounded.Highlight, + if (selectionHasHighlight) "Recolor" else "Highlight", + if (selectionHasHighlight) currentColor else menuFg + ) { onHighlightSelection() } + SelectionMenuItem(Icons.Rounded.FormatUnderlined, "Underline", menuFg) { onUnderlineSelection() } + SelectionMenuItem(Icons.Rounded.StrikethroughS, "Strike", menuFg) { onStrikeSelection() } + if (selectionHasHighlight) { + SelectionMenuItem(Icons.Rounded.Delete, "Delete", Color(0xFFEF5350)) { + // Drop every highlight sitting on a selected word, then clear the + // selection so the menu dismisses. + marks.removeAll { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId in selectedOcrIds } + onClearOcrSelection() + } + } + } + Box(Modifier.fillMaxWidth().height(1.dp).background(Color.White.copy(0.08f))) + Row( + Modifier + .padding(horizontal = 10.dp, vertical = 8.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + editorPalette.forEach { c -> + val isSel = c.value == currentColor.value + Box( + Modifier + .size(16.dp) + .clip(CircleShape) + .background(c) + .border( + width = if (isSel) 2.dp else 0.dp, + color = Color.White.copy(0.85f), + shape = CircleShape + ) + .clickable { onSetColorLong(c.value.toLong()) } + ) + } } } } @@ -802,7 +874,7 @@ internal fun PdfContinuousPage( val density = LocalDensity.current val gapPx = with(density) { 10.dp.toPx() } val barHpx = with(density) { 44.dp.toPx() } - val barWpx = with(density) { 96.dp.toPx() } + val barWpx = with(density) { 176.dp.toPx() } val placeBelow = anchorRect.top < barHpx + gapPx val by = (if (placeBelow) anchorRect.bottom + gapPx else anchorRect.top - barHpx - gapPx) .coerceIn(0f, (csz.height - barHpx).coerceAtLeast(0f)) @@ -812,19 +884,29 @@ internal fun PdfContinuousPage( Row( Modifier .offset { IntOffset(bx.roundToInt(), by.roundToInt()) } - .clip(RoundedCornerShape(22.dp)) - .background(Color(0xFF1C1F26).copy(0.97f)) - .border(1.dp, Color.White.copy(0.14f), RoundedCornerShape(22.dp)) - .padding(horizontal = 4.dp, vertical = 3.dp), + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF232629)) + .border(1.dp, Color.White.copy(0.08f), RoundedCornerShape(10.dp)) + .padding(horizontal = 2.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { + // Same Edit affordance as shapes/text/notes — opens the shared recolor + // popover (ShapeEditorPopup handles highlight/underline/strike too). + BasicText( + "Edit", + style = TextStyle(Color(0xFFECECEC), 13.sp, FontWeight.Medium), + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { onEditShape(selectedMarkupIndex) } + .padding(horizontal = 16.dp, vertical = 10.dp) + ) BasicText( "Delete", - style = TextStyle(Color(0xFFFF6B6B), 13.sp, FontWeight.SemiBold), + style = TextStyle(Color(0xFFEF5350), 13.sp, FontWeight.Medium), modifier = Modifier - .clip(RoundedCornerShape(18.dp)) + .clip(RoundedCornerShape(6.dp)) .clickable { onDeleteMarkup(selectedMarkupIndex) } - .padding(horizontal = 16.dp, vertical = 9.dp) + .padding(horizontal = 16.dp, vertical = 10.dp) ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt index 77ada9a..dd774e1 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt @@ -367,20 +367,22 @@ internal fun PdfViewerBottomToolbar( } showOcrTools -> { + // Plain, native-menu-style neutral buttons — no per-action rainbow + // tinting — matching the in-context selection toolbar's look. LiquidButton(onClick = onSelectAllText, backdrop = backdrop, surfaceColor = chip) { BasicText(stringResource(R.string.viewer_select_all), style = TextStyle(fg, 12.sp, FontWeight.Medium)) } - LiquidButton(onClick = onCopyText, backdrop = backdrop, tint = Color(0xFF7E57C2)) { - BasicText(stringResource(R.string.copy), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + LiquidButton(onClick = onCopyText, backdrop = backdrop, surfaceColor = chip) { + BasicText(stringResource(R.string.copy), style = TextStyle(fg, 12.sp, FontWeight.Medium)) } - LiquidButton(onClick = onHighlightSelected, backdrop = backdrop, tint = Color(0xFFFFB300)) { - BasicText(stringResource(R.string.viewer_highlight), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + LiquidButton(onClick = onHighlightSelected, backdrop = backdrop, surfaceColor = chip) { + BasicText(stringResource(R.string.viewer_highlight), style = TextStyle(fg, 12.sp, FontWeight.Medium)) } - LiquidButton(onClick = onUnderlineSelected, backdrop = backdrop, tint = Color(0xFF4CAF50)) { - BasicText(stringResource(R.string.viewer_underline), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + LiquidButton(onClick = onUnderlineSelected, backdrop = backdrop, surfaceColor = chip) { + BasicText(stringResource(R.string.viewer_underline), style = TextStyle(fg, 12.sp, FontWeight.Medium)) } - LiquidButton(onClick = onStrikeSelected, backdrop = backdrop, tint = Color(0xFFEF5350)) { - BasicText(stringResource(R.string.viewer_strike), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + LiquidButton(onClick = onStrikeSelected, backdrop = backdrop, surfaceColor = chip) { + BasicText(stringResource(R.string.viewer_strike), style = TextStyle(fg, 12.sp, FontWeight.Medium)) } LiquidButton(onClick = onClearTextSelection, backdrop = backdrop, surfaceColor = chip) { BasicText(stringResource(R.string.viewer_clear), style = TextStyle(fg, 12.sp, FontWeight.Medium)) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt index 0321c7b..3d062a3 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt @@ -149,6 +149,10 @@ internal sealed class PdfMarkup { internal fun PdfMarkup.isShape(): Boolean = this is PdfMarkup.StrokeMarkup || this is PdfMarkup.RectMarkup || this is PdfMarkup.OvalMarkup || this is PdfMarkup.LineMarkup +/** Anything [ShapeEditorPopup] can recolour — shapes plus OCR-anchored highlight/underline/strike. */ +internal fun PdfMarkup.isRecolorable(): Boolean = isShape() || + this is PdfMarkup.TextBlockHighlightMarkup || this is PdfMarkup.TextBlockLineMarkup + /** Markups that support the generic select → move / resize transform (everything the user * places freely, except images which have their own dedicated toolbar path). */ internal fun PdfMarkup.isTransformable(): Boolean = this is PdfMarkup.StrokeMarkup || @@ -227,6 +231,8 @@ internal fun PdfMarkup.shapeColor(): Color = when (this) { is PdfMarkup.RectMarkup -> color is PdfMarkup.OvalMarkup -> color is PdfMarkup.LineMarkup -> color + is PdfMarkup.TextBlockHighlightMarkup -> color + is PdfMarkup.TextBlockLineMarkup -> color else -> Color(0xFF1976D2) } @@ -235,6 +241,8 @@ internal fun PdfMarkup.recolored(c: Color): PdfMarkup = when (this) { is PdfMarkup.RectMarkup -> copy(color = c) is PdfMarkup.OvalMarkup -> copy(color = c) is PdfMarkup.LineMarkup -> copy(color = c) + is PdfMarkup.TextBlockHighlightMarkup -> copy(color = c) + is PdfMarkup.TextBlockLineMarkup -> copy(color = c) else -> this } @@ -420,6 +428,32 @@ internal fun ocrSelectionHandleAnchors( return Offset(firstRect.left, firstRect.bottom) to Offset(lastRect.right, lastRect.bottom) } +/** + * Rendered line height (in the same canvas-local pixels as [ocrSelectionHandleAnchors]'s + * anchors) for the first/last selected line. The page is drawn at whatever zoom/fit-width + * scale is currently active, so a FIXED dp handle size looks tiny at 200% zoom and gigantic + * at fit-width (a full page's line height can be under 20px there) — handles must scale with + * the text they're anchored to, exactly like the platform text selector's do. + */ +internal fun ocrSelectionHandleLineHeights( + blocks: List, + ranges: List, + frame: Rect +): Pair? { + if (ranges.isEmpty()) return null + val blocksById = blocks.associateBy { it.id } + val ordered = ranges.sortedWith( + compareBy( + { blocksById[it.blockId]?.top ?: Float.MAX_VALUE }, + { blocksById[it.blockId]?.left ?: Float.MAX_VALUE }, + { it.start } + ) + ) + val firstBlock = blocksById[ordered.first().blockId] ?: return null + val lastBlock = blocksById[ordered.last().blockId] ?: return null + return (firstBlock.bottom - firstBlock.top) * frame.height to (lastBlock.bottom - lastBlock.top) * frame.height +} + /** Custom organic handle used by the PDF selection layer; deliberately not a platform handle. */ internal fun DrawScope.drawTextSelectionHandle( anchor: Offset, diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt index 206b6fc..7d810d8 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -137,6 +138,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt +@OptIn(kotlinx.coroutines.FlowPreview::class) @Composable fun PdfViewerScreen( backdrop: LayerBackdrop, @@ -558,6 +560,12 @@ fun PdfViewerScreen( val safePageCount = state.pageCount.coerceAtLeast(1) val viewerScope = rememberCoroutineScope() val currentPageIndex = listState.firstVisibleItemIndex.coerceIn(0, safePageCount - 1) + // The bottom SelectText toolbar must act on whichever page actually holds the current OCR + // selection, not the viewport-derived currentPageIndex — otherwise scrolling after selecting + // (or the selection sitting on a page whose bottom sliver is visible, not its top) makes + // Highlight/Underline/Strike/Copy silently target the wrong page's empty selection. + val selectionPageIndex = state.selectedOcrRangesByPage.entries + .firstOrNull { it.value.isNotEmpty() }?.key ?: currentPageIndex // Live backdrop that captures the ACTUAL rendered page column (dark bg + PDF // pages), so the glass chrome reflects real content instead of the static @@ -615,12 +623,30 @@ fun PdfViewerScreen( val panelFgSoft = if (isLightChrome) Color(0xFF15171C).copy(0.62f) else Color.White.copy(0.62f) val chromeField = if (isLightChrome) Color.Black.copy(0.10f) else Color.White.copy(0.10f) - // The top visible page drives text extraction + bitmap caching. + // The top visible page drives text extraction, the chrome-tint sampling, and (via + // `selectionPageIndex`/toolbar targets) which page an action applies to — all of that needs to + // track the scroll position immediately, every tick. LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } .distinctUntilChanged() .collect { idx -> viewModel.onPageChanged(idx.coerceIn(0, safePageCount - 1)) } } + // Bitmap-cache eviction is a separate, DEBOUNCED signal. It used to run on every single one of + // those ticks: during a fast scroll or fling through a many-page document (a converted .pptx, + // scrolled through quickly, was the easiest way to see it) the "current page" advances every + // few milliseconds, and evicting far-from-current bitmaps on every tick recycled pages that + // were still transiting through the viewport a frame later — the freshly-evicted page then had + // to re-render from scratch before it could be shown again, which is what "flickering"/ + // "buffering" while scrolling actually was: real re-render work, triggered far more often than + // the eviction needed to happen. Coalescing rapid ticks into one sweep after they stop still + // bounds memory (nothing pins pages forever, it just evicts once per settled position instead + // of once per pixel) while never evicting a page mid-transit. + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex.coerceIn(0, safePageCount - 1) } + .distinctUntilChanged() + .debounce(180) + .collect { idx -> viewModel.trimBitmapCache(idx) } + } // Scroll to the page holding the active find match. LaunchedEffect(state.currentMatchIndex, state.findMatches) { @@ -661,6 +687,14 @@ fun PdfViewerScreen( ) { val renderWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() }.coerceAtLeast(720) + // Warms the pages just ahead of (and one behind) wherever scrolling currently is, so a page's + // render has a head start instead of only beginning once it scrolls into view — undebounced + // is fine here since `prefetchAround`/`renderPage` are no-ops for a page already rendered or + // already in flight. + LaunchedEffect(currentPageIndex, renderWidthPx) { + viewModel.prefetchAround(context, currentPageIndex, renderWidthPx) + } + var containerHeightPx by remember { mutableStateOf(0) } // ── Adaptive control ink, sampled per bar (Apple-style) ────────────────────────────── @@ -917,11 +951,33 @@ fun PdfViewerScreen( // range so a re-tap replaces the colour instead of stacking layers. m.removeAll { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end } m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end)) + recordEdit(page) } // Keep the selection live so the pill immediately offers Recolor / Delete — // this is the fix for "after highlighting there's no delete option". lastInteractionAtMs = System.currentTimeMillis() }, + onUnderlineSelection = { + val m = getPageMarks(page) + selectedTextRanges(page).forEach { range -> + if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end && !it.strikeThrough }) { + m.add(PdfMarkup.TextBlockLineMarkup(range.blockId, Color(currentColorLong), 3f, 1f, false, range.start, range.end)) + recordEdit(page) + } + } + lastInteractionAtMs = System.currentTimeMillis() + }, + onStrikeSelection = { + val m = getPageMarks(page) + selectedTextRanges(page).forEach { range -> + if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end && it.strikeThrough }) { + m.add(PdfMarkup.TextBlockLineMarkup(range.blockId, Color(currentColorLong), 3f, 1f, true, range.start, range.end)) + recordEdit(page) + } + } + lastInteractionAtMs = System.currentTimeMillis() + }, + onSetColorLong = { currentColorLong = it }, onSelectAll = { val ids = state.ocrBlocksByPage[page].orEmpty().map { it.id }.toSet() if (ids.isNotEmpty()) viewModel.selectOcrBlocks(page, ids, append = false) @@ -1092,31 +1148,34 @@ fun PdfViewerScreen( if (ids.isNotEmpty()) viewModel.selectOcrBlocks(currentPageIndex, ids, false) }, onCopyText = { - viewModel.getSelectedOcrText(currentPageIndex).takeIf { it.isNotBlank() } + viewModel.getSelectedOcrText(selectionPageIndex).takeIf { it.isNotBlank() } ?.let { clipboard.setText(AnnotatedString(it)) } }, onHighlightSelected = { - val m = getPageMarks(currentPageIndex) - selectedTextRanges(currentPageIndex).forEach { range -> + val m = getPageMarks(selectionPageIndex) + selectedTextRanges(selectionPageIndex).forEach { range -> if (!m.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end }) m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end)) + recordEdit(selectionPageIndex) } }, onUnderlineSelected = { - val m = getPageMarks(currentPageIndex) - selectedTextRanges(currentPageIndex).forEach { range -> + val m = getPageMarks(selectionPageIndex) + selectedTextRanges(selectionPageIndex).forEach { range -> if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end && !it.strikeThrough }) m.add(PdfMarkup.TextBlockLineMarkup(range.blockId, Color(currentColorLong), 3f, 1f, false, range.start, range.end)) + recordEdit(selectionPageIndex) } }, onStrikeSelected = { - val m = getPageMarks(currentPageIndex) - selectedTextRanges(currentPageIndex).forEach { range -> + val m = getPageMarks(selectionPageIndex) + selectedTextRanges(selectionPageIndex).forEach { range -> if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end && it.strikeThrough }) m.add(PdfMarkup.TextBlockLineMarkup(range.blockId, Color(currentColorLong), 3f, 1f, true, range.start, range.end)) + recordEdit(selectionPageIndex) } }, - onClearTextSelection = { viewModel.clearOcrSelection(currentPageIndex) }, + onClearTextSelection = { viewModel.clearOcrSelection(selectionPageIndex) }, onSetColorLong = { currentColorLong = it }, onSetStrokeWidth = { currentStrokeWidth = it }, onDismissExportFeedback = { viewModel.clearExportFeedback() }, @@ -1233,7 +1292,7 @@ fun PdfViewerScreen( editingShapePage?.let { shapePage -> val list = getPageMarks(shapePage) val shape = list.getOrNull(editingShapeIndex) - if (shape != null && shape.isShape()) { + if (shape != null && shape.isRecolorable()) { ShapeEditorPopup( initialColor = shape.shapeColor(), backdrop = contentBackdrop, @@ -1244,7 +1303,7 @@ fun PdfViewerScreen( field = chromeField, onColorChange = { c -> val cur = list.getOrNull(editingShapeIndex) - if (cur != null && cur.isShape()) list[editingShapeIndex] = cur.recolored(c) + if (cur != null && cur.isRecolorable()) list[editingShapeIndex] = cur.recolored(c) lastInteractionAtMs = System.currentTimeMillis() }, onDelete = { diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt index 0ba8e02..3ed9414 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt @@ -345,7 +345,8 @@ fun SettingsScreen( data class LangOption(val code: String, val label: String) val langs = listOf( LangOption("en", stringResource(R.string.language_english)), - LangOption("pt-BR", stringResource(R.string.language_portuguese)) + LangOption("pt-BR", stringResource(R.string.language_portuguese)), + LangOption("es", stringResource(R.string.language_spanish)) ) val accent = Color(0xFF0088FF) langs.forEach { opt -> @@ -722,28 +723,26 @@ fun SettingsScreen( BasicText(stringResource(R.string.settings_licenses), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } - LicenseItem( - name = "AndroidLiquidGlass", - author = "Kyant", - license = "Apache License 2.0", - url = "https://github.com/Kyant0/AndroidLiquidGlass", - labelColor = label, - subColor = sub - ) - - Box( - Modifier.fillMaxWidth().height(1.dp) - .background(if (isLight) Color.Black.copy(0.04f) else Color.White.copy(0.06f)) - ) - - LicenseItem( - name = "Pdf_Tools", - author = "Karna14314", - license = "PDF viewer zoom/pan reference", - url = "https://github.com/Karna14314/Pdf_Tools", - labelColor = label, - subColor = sub - ) + // Everything third-party that ships inside the app. This is the attribution the + // Apache-2.0 and BSD notices actually require, so it has to list what the build really + // pulls in — not just the two entries it started with. THIRD_PARTY_NOTICES.md carries + // the full text; keep the two in step when a dependency is added or dropped. + OpenSourceCredits.forEachIndexed { index, credit -> + if (index > 0) { + Box( + Modifier.fillMaxWidth().height(1.dp) + .background(if (isLight) Color.Black.copy(0.04f) else Color.White.copy(0.06f)) + ) + } + LicenseItem( + name = credit.name, + author = credit.author, + license = credit.license, + url = credit.url, + labelColor = label, + subColor = sub + ) + } Box( Modifier.fillMaxWidth().height(1.dp) @@ -762,6 +761,72 @@ fun SettingsScreen( } } +private class Credit(val name: String, val author: String, val license: String, val url: String) + +/** + * Ordered roughly by how much of the app each one carries. + * + * Note what is *not* here: the .xlsx reader and the PowerPoint slide renderer are written in this + * repo against the published OOXML layout, not borrowed. That was a size decision — the libraries + * that read Office formats properly on Android (POI's OOXML half plus XmlBeans at ~17 MB, or + * OpenDocument.core's 100 MB AAR) are far more than this app can carry for a viewer. POI appears + * below only for the *legacy* binary .doc/.xls/.ppt formats, which are not XML and cannot be read + * this way. Word layout is the one place a library won on merit: docx-preview delegates to the + * browser engine the phone already has, so it costs ~48 KB rather than tens of megabytes. + */ +private val OpenSourceCredits = listOf( + Credit( + "AndroidLiquidGlass", "Kyant", + "Apache License 2.0", + "https://github.com/Kyant0/AndroidLiquidGlass" + ), + Credit( + "PdfBox-Android", "Tom Roush", + "Apache License 2.0 — PDF text, forms and annotation export", + "https://github.com/TomRoush/PdfBox-Android" + ), + Credit( + "Apache POI", "The Apache Software Foundation", + "Apache License 2.0 — legacy .doc / .xls / .ppt reading", + "https://poi.apache.org" + ), + Credit( + "ML Kit Text Recognition", "Google", + "Apache License 2.0 — bundled on-device OCR, no network", + "https://developers.google.com/ml-kit/vision/text-recognition" + ), + Credit( + "Tesseract4Android", "Adaptech s.r.o.", + "Apache License 2.0 — offline OCR fallback", + "https://github.com/adaptech-cz/Tesseract4Android" + ), + Credit( + "Tesseract OCR", "Google / Tesseract contributors", + "Apache License 2.0", + "https://github.com/tesseract-ocr/tesseract" + ), + Credit( + "Leptonica", "Dan Bloomberg", + "BSD 2-Clause — image processing behind Tesseract", + "https://github.com/DanBloomberg/leptonica" + ), + Credit( + "docx-preview", "Volodymyr Baydalka", + "Apache License 2.0 — Word document layout", + "https://github.com/VolodymyrBaydalka/docxjs" + ), + Credit( + "JSZip", "Stuart Knightley", + "MIT License (used under MIT of its MIT/GPLv3 dual licence)", + "https://github.com/Stuk/jszip" + ), + Credit( + "Pdf_Tools", "Karna14314", + "PDF viewer zoom/pan reference", + "https://github.com/Karna14314/Pdf_Tools" + ) +) + private fun Modifier.liquidGlassSection(isLight: Boolean): Modifier { val containerColor = if (isLight) Color.White.copy(0.68f) else Color(0xFF161820).copy(0.72f) val borderColor = if (isLight) Color.White.copy(0.80f) else Color.White.copy(0.12f) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt index 6c46f62..175a205 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt @@ -1,7 +1,11 @@ package com.chethan616.clearpdf.ui.screen +import android.os.SystemClock +import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -15,7 +19,11 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource @@ -23,12 +31,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size @@ -65,10 +76,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color @@ -78,6 +92,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import kotlinx.coroutines.launch import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -86,6 +102,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.distinctUntilChanged import com.chethan616.clearpdf.R import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.GlassTitlePill @@ -100,6 +119,7 @@ import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor import com.chethan616.clearpdf.ui.viewmodel.SpreadsheetViewModel import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.shapes.Capsule import com.kyant.shapes.RoundedRectangle private val CELL_W = 132.dp @@ -272,6 +292,22 @@ fun SpreadsheetViewerScreen( }, modifier = Modifier.fillMaxSize() ) + + // Jump-scrubber for long sheets — the fast-flick fling on the grid itself + // covers distance quickly, but a WPS/Excel-style rail is still the more + // precise way to land on a specific far-off row without counting flicks. + SheetRowScrubber( + listState = gridListState, + rowCount = currentSheet.rows.size, + backdrop = backdrop, + chromeGlass = chromeGlass, + accent = accent, + text = text, + isDark = isDark, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 4.dp) + ) } } } @@ -301,7 +337,8 @@ fun SpreadsheetViewerScreen( ) { // "B7 · Cell value" — the ref matters once you can change what's in it. BasicText( - "${colLetter(cell.col)}${cell.row + 1} · ${stringResource(R.string.sheet_cell_value)}", + "${currentSheet?.labelAt(cell.col) ?: ""}${currentSheet?.rowNumberAt(cell.row) ?: (cell.row + 1)}" + + " · ${stringResource(R.string.sheet_cell_value)}", style = TextStyle(sub, 11.sp, FontWeight.Bold, letterSpacing = 0.8.sp) ) @@ -522,7 +559,7 @@ private fun SheetGrid( val baseWidths = remember(sheet) { val sample = sheet.rows.take(200) List(colCount) { c -> - var maxLen = colLetter(c).length + var maxLen = sheet.labelAt(c).length for (row in sample) { val len = row.getOrNull(c)?.length ?: 0 if (len > maxLen) maxLen = len @@ -531,6 +568,15 @@ private fun SheetGrid( } } + // Row-number gutter, sized to the widest number it will ever show so the grid never shifts + // sideways mid-scroll. It is pinned outside the horizontal scroll, so the row you are reading + // keeps its number no matter how far right the sheet is scrolled — the same thing Excel does, + // and the reason a spreadsheet is navigable at all once it is wider than the screen. + val gutterWidth = remember(sheet, zoom) { + val digits = (sheet.rowNumbers.lastOrNull() ?: sheet.rows.size).toString().length + ((digits * 8 + 22).dp * zoom).coerceIn(30.dp, 76.dp) + } + Column( // No clip/border of its own any more — the glass panel it now sits inside is the container, // and a 12 dp rounded outline inside a 28 dp glass capsule read as a box within a box. @@ -553,7 +599,9 @@ private fun SheetGrid( BoxWithConstraints(Modifier.fillMaxSize()) { // If the content is narrower than the viewport, stretch every column proportionally so // the grid fills the width (kills the empty right gap); wider sheets keep scrolling. - val available = maxWidth + // The gutter is subtracted first — it sits outside the scroll, so the columns only ever + // get what is left of the viewport. + val available = (maxWidth - gutterWidth).coerceAtLeast(80.dp) val baseSum = baseWidths.fold(0.dp) { acc, w -> acc + w } val fill = if (baseSum > 0.dp && baseSum < available) available / baseSum else 1f val colWidths = baseWidths.map { it * fill * zoom } @@ -584,46 +632,106 @@ private fun SheetGrid( } val leadWidth = starts[window.first].dp val tailWidth = (starts[colCount] - starts[window.last + 1]).dp + // For the grid-line draw pass below — converts the dp-unit `starts`/`colWidths` numbers + // straight to px without a `LocalDensity.current` lookup inside every row. + val pxPerDp = with(LocalDensity.current) { 1.dp.toPx() } Column(Modifier.fillMaxSize()) { - // Sticky column-letter header. - Row(Modifier.fillMaxWidth().background(headerBg).horizontalScroll(hScroll)) { - Spacer(Modifier.width(leadWidth)) - for (c in window) { - Box( - Modifier.width(colWidths[c]).heightIn(min = cellH).border(0.5.dp, gridLine).padding(horizontal = 8.dp), - contentAlignment = Alignment.CenterStart - ) { - BasicText(colLetter(c), style = TextStyle(sub, headerSize, FontWeight.Bold)) + // Sticky column-letter header, with the gutter's blank corner cell to its left. + Row(Modifier.fillMaxWidth().background(headerBg)) { + Box(Modifier.width(gutterWidth).heightIn(min = cellH).border(0.5.dp, gridLine)) + Row(Modifier.weight(1f).horizontalScroll(hScroll)) { + Spacer(Modifier.width(leadWidth)) + for (c in window) { + Box( + Modifier.width(colWidths[c]).heightIn(min = cellH).border(0.5.dp, gridLine).padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + // The sheet's own label, not the position — a sheet that hides a + // column still reads "… G, I …" here, exactly as it does in Excel. + BasicText(sheet.labelAt(c), style = TextStyle(sub, headerSize, FontWeight.Bold)) + } } + Spacer(Modifier.width(tailWidth)) } - Spacer(Modifier.width(tailWidth)) } - LazyColumn(Modifier.fillMaxWidth().weight(1f), state = listState) { + LazyColumn( + Modifier.fillMaxWidth().weight(1f), + state = listState, + // Rows only. The column axis is at most a few screens wide, so there is nothing + // there that repeated swipes are a tiring way to cross. + flingBehavior = rememberStackingFlingBehavior() + ) { itemsIndexed(sheet.rows) { rIdx, row -> - Row(Modifier.fillMaxWidth().horizontalScroll(hScroll)) { - Spacer(Modifier.width(leadWidth).heightIn(min = cellH)) - for (c in window) { - val v = row.getOrElse(c) { "" } - val isCurrent = currentCell?.first == rIdx && currentCell.second == c - val cellBg = when { - isCurrent -> currentBg - (rIdx.toLong() * 1_000_000L + c) in matchSet -> matchBg - rIdx % 2 == 1 -> rowAlt - else -> Color.Transparent - } - Box( - // Blank cells are tappable too — you have to be able to select an - // empty cell to type into it. - Modifier.width(colWidths[c]).heightIn(min = cellH).background(cellBg).border(0.5.dp, gridLine) - .clickable { onCellTap(rIdx, c, v) } - .padding(horizontal = 8.dp), - contentAlignment = Alignment.CenterStart - ) { - BasicText(v, style = TextStyle(text, cellSize), maxLines = 1, overflow = TextOverflow.Ellipsis) + val rowMatched = currentCell?.first == rIdx + Row(Modifier.fillMaxWidth()) { + Box( + Modifier.width(gutterWidth).heightIn(min = cellH) + .background(if (rowMatched) accent.copy(0.22f) else headerBg) + .border(0.5.dp, gridLine), + contentAlignment = Alignment.Center + ) { + BasicText( + sheet.rowNumberAt(rIdx).toString(), + style = TextStyle( + if (rowMatched) accent else sub, + headerSize, + if (rowMatched) FontWeight.Bold else FontWeight.Medium + ), + maxLines = 1 + ) + } + Row( + Modifier + .weight(1f) + .horizontalScroll(hScroll) + // One draw pass for the whole row's grid lines, instead of a + // `border()` modifier on every cell. A `border` is a real layout + // + draw node, and with 9–16 visible columns that meant up to + // ~16 extra nodes composing for every row a fast fling scrolled + // into view — on a 500–1000 row sheet under the boosted fling + // below, that per-row node churn was more than composition could + // keep up with, which showed as the list visibly pausing to + // "catch up" every screenful. + .drawBehind { + val stroke = 0.5.dp.toPx() + val bottom = size.height + for (c in window) { + val right = (starts[c] + colWidths[c].value) * pxPerDp + drawLine(gridLine, Offset(right, 0f), Offset(right, bottom), stroke) + } + drawLine(gridLine, Offset(0f, bottom), Offset(size.width, bottom), stroke) + } + ) { + Spacer(Modifier.width(leadWidth).heightIn(min = cellH)) + for (c in window) { + val v = row.getOrElse(c) { "" } + val isCurrent = rowMatched && currentCell.second == c + val cellBg = when { + isCurrent -> currentBg + (rIdx.toLong() * 1_000_000L + c) in matchSet -> matchBg + rIdx % 2 == 1 -> rowAlt + else -> Color.Transparent + } + val cellInteraction = remember { MutableInteractionSource() } + Box( + // Blank cells are tappable too — you have to be able to select + // an empty cell to type into it. No ripple: a spreadsheet cell + // gives its own feedback (the value popup opens instantly), and + // skipping the indication drops one more subsystem — attaching + // and tearing down a ripple instance — from every cell's cost. + Modifier.width(colWidths[c]).heightIn(min = cellH).background(cellBg) + .clickable(interactionSource = cellInteraction, indication = null) { + onCellTap(rIdx, c, v) + } + .padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicText(v, style = TextStyle(text, cellSize), maxLines = 1, overflow = TextOverflow.Ellipsis) + } } + Spacer(Modifier.width(tailWidth)) } - Spacer(Modifier.width(tailWidth)) } } } @@ -632,12 +740,206 @@ private fun SheetGrid( } } -/** 0→A, 25→Z, 26→AA … spreadsheet column labels. */ -private fun colLetter(index: Int): String { - var i = index - val sb = StringBuilder() - while (i >= 0) { sb.insert(0, 'A' + (i % 26)); i = i / 26 - 1 } - return sb.toString() +private val SheetScrubberTrackHeight = 208.dp + +/** + * A WPS/Excel-style vertical jump rail for the row axis — drag to scroll to any row in one motion, + * with a small floating "N/Total" badge that tracks the finger, instead of counting flicks to get + * from row 12 to row 940. + * + * Modelled directly on [PageScrubber] (the PDF viewer's page rail): same track/thumb sizing, same + * tap-to-jump + drag-to-scrub gesture, same haptic tick per step. The one real difference is that a + * spreadsheet has no per-row render cost the way a PDF page does, so this scrolls the list live on + * every drag tick instead of only on release — the sheet content itself becomes the "preview", + * which is what the WPS reference screenshot actually shows (the grid moving under the thumb, not a + * separate popup). + * + * Only shown for sheets long enough that the rail is a shortcut rather than clutter — a 20-row sheet + * scrolls in one swipe already. + */ +@Composable +private fun SheetRowScrubber( + listState: androidx.compose.foundation.lazy.LazyListState, + rowCount: Int, + backdrop: LayerBackdrop, + chromeGlass: Color, + accent: Color, + text: Color, + isDark: Boolean, + modifier: Modifier = Modifier +) { + if (rowCount < 60) return + + val view = LocalView.current + var isDragging by remember { mutableStateOf(false) } + var dragRow by remember { mutableIntStateOf(0) } + val lastSpan = (rowCount - 1).coerceAtLeast(1) + + // Follow normal (non-rail) scrolling when the rail itself isn't being touched. + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { idx -> if (!isDragging) dragRow = idx.coerceIn(0, lastSpan) } + } + + // Haptic tick + live scroll on every row the drag crosses. + LaunchedEffect(dragRow, isDragging) { + if (isDragging) { + runCatching { view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) } + runCatching { listState.scrollToItem(dragRow) } + } + } + + val fraction by animateFloatAsState( + targetValue = (dragRow.toFloat() / lastSpan).coerceIn(0f, 1f), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy), + label = "sheetScrubFraction" + ) + val trackWidth by animateDpAsState(if (isDragging) 8.dp else 4.dp, spring(stiffness = Spring.StiffnessMedium), label = "sheetTrackWidth") + val thumbHeight by animateDpAsState(if (isDragging) 40.dp else 30.dp, spring(stiffness = Spring.StiffnessMedium), label = "sheetThumbHeight") + + Box(modifier) { + Box( + Modifier + .align(Alignment.CenterEnd) + .height(SheetScrubberTrackHeight) + .width(28.dp) + .pointerInput(rowCount) { + detectTapGestures { offset -> + val target = ((offset.y / size.height) * lastSpan).roundToInt().coerceIn(0, lastSpan) + dragRow = target + runCatching { view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } + } + } + .pointerInput(rowCount) { + detectDragGestures( + onDragStart = { start -> + isDragging = true + dragRow = ((start.y / size.height) * lastSpan).roundToInt().coerceIn(0, lastSpan) + }, + onDrag = { change, _ -> + change.consume() + dragRow = ((change.position.y / size.height) * lastSpan).roundToInt().coerceIn(0, lastSpan) + }, + onDragEnd = { isDragging = false }, + onDragCancel = { isDragging = false } + ) + }, + contentAlignment = Alignment.Center + ) { + Box( + Modifier + .width(trackWidth) + .height(SheetScrubberTrackHeight) + .clip(RoundedCornerShape(50)) + .background(if (isDark) Color.White.copy(0.14f) else Color.Black.copy(0.10f)), + contentAlignment = Alignment.TopCenter + ) { + Box( + Modifier + .padding(top = ((SheetScrubberTrackHeight - thumbHeight) * fraction).coerceAtLeast(0.dp)) + .width(if (isDragging) 8.dp else 4.dp) + .height(thumbHeight) + .clip(RoundedCornerShape(50)) + .background(if (isDragging) accent else accent.copy(0.55f)) + ) + } + } + + // A small pill badge — "12/940", not "Row 12 / 940" in a wide card. Sized to match the 40 dp + // search-icon circle it sits beside: a plain `CircleShape` can't hold a 4-digit fraction + // without clipping, so this starts as a near-circle for short numbers and only widens as far + // as the digits actually need, via `defaultMinSize` rather than a fixed wide padding. + AnimatedVisibility( + visible = isDragging, + enter = fadeIn(tween(140)) + scaleIn(initialScale = 0.9f, animationSpec = tween(160)), + exit = fadeOut(tween(180)) + scaleOut(targetScale = 0.92f), + modifier = Modifier.align(Alignment.CenterEnd) + ) { + val yOffset = (SheetScrubberTrackHeight * fraction - SheetScrubberTrackHeight / 2f).coerceIn(-90.dp, 90.dp) + Box( + Modifier + .offset(x = (-32).dp, y = yOffset) + .defaultMinSize(minWidth = 26.dp, minHeight = 26.dp) + .viewerGlass(backdrop, chromeGlass, shape = { Capsule }) + .padding(horizontal = 7.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + BasicText( + stringResource(R.string.sheet_row_of, dragRow + 1, rowCount), + style = TextStyle(text, 10.sp, FontWeight.SemiBold), + maxLines = 1 + ) + } + } + } +} + +/** + * A fling behaviour that stacks: flick fast in the same direction again before the previous fling + * has settled and the next one travels further, up to [MaxFlingMultiplier] times a normal one. + * + * A spreadsheet is the one screen in this app that is routinely thousands of rows long, and the + * platform fling is tuned for lists you read rather than lists you traverse — reaching row 4000 + * takes a tiring number of identical swipes. Repeated fast swipes are already the gesture people + * reach for there, so this reads them as one intent and gives them distance. + * + * It boosts the *initial velocity* and then hands off to the platform's own decay curve, so the + * motion is the standard one throughout — faster, never jumpier. Nothing snaps or teleports. + * + * Only deliberate flicks count toward a streak: a swipe under [MinStreakVelocityDp] per second is + * someone positioning carefully, and stacking those would make precise scrolling impossible. + * Changing direction, or pausing past [StreakWindowMillis], resets it. + */ +@Composable +private fun rememberStackingFlingBehavior(): FlingBehavior { + val base = ScrollableDefaults.flingBehavior() + val minVelocity = with(LocalDensity.current) { MinStreakVelocityDp.dp.toPx() } + return remember(base, minVelocity) { StackingFlingBehavior(base, minVelocity) } +} + +/** dp per second below which a swipe is treated as positioning, not as a fast flick. */ +private const val MinStreakVelocityDp = 1200f + +/** How long after a fling a follow-up still counts as part of the same burst. */ +private const val StreakWindowMillis = 320L + +/** Each consecutive fast flick adds this much of a normal fling's velocity. */ +private const val FlingBoostPerSwipe = 0.9f + +/** Ceiling, so a long burst can't launch the sheet somewhere unrecoverable. */ +private const val MaxFlingMultiplier = 4f + +private class StackingFlingBehavior( + private val base: FlingBehavior, + private val minVelocity: Float +) : FlingBehavior { + + private var lastDirection = 0 + private var lastFlingAtMillis = 0L + private var streak = 0 + + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + val now = SystemClock.uptimeMillis() + val direction = when { + initialVelocity > 0f -> 1 + initialVelocity < 0f -> -1 + else -> 0 + } + val isFastFlick = abs(initialVelocity) >= minVelocity + val continuesBurst = direction != 0 && + direction == lastDirection && + now - lastFlingAtMillis <= StreakWindowMillis + + streak = if (isFastFlick && continuesBurst) streak + 1 else 0 + lastDirection = direction + lastFlingAtMillis = now + + val multiplier = (1f + streak * FlingBoostPerSwipe).coerceAtMost(MaxFlingMultiplier) + // Delegating rather than animating here is the point: the decay curve, the over-scroll + // handover and the "velocity left over" contract all stay exactly the platform's. + return with(base) { performFling(initialVelocity * multiplier) } + } } /** Share the (mirrored) file. file:// → FileProvider content:// so it isn't exposed → no crash. */ diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt b/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt index dbc0829..9aa0f00 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt @@ -45,7 +45,11 @@ object LocaleHelper { private fun normalizeLanguageTag(languageTag: String): String { val locale = Locale.forLanguageTag(languageTag.replace('_', '-')) - return if (locale.language.equals("pt", ignoreCase = true)) "pt-BR" else "en" + return when { + locale.language.equals("pt", ignoreCase = true) -> "pt-BR" + locale.language.equals("es", ignoreCase = true) -> "es" + else -> "en" + } } fun normalizeForUi(languageTag: String): String = normalizeLanguageTag(languageTag) @@ -99,6 +103,7 @@ object LocaleHelper { fun getLanguageDisplayName(languageTag: String): String { return when (normalizeLanguageTag(languageTag)) { "pt-BR", "pt" -> "Português (Brasil)" + "es" -> "Español" "en" -> "English" else -> "English" } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt index 450c5d8..6c2b69a 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt @@ -1,33 +1,68 @@ package com.chethan616.clearpdf.ui.viewmodel +import android.content.ContentValues import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.kyant.pdfcore.converter.PdfConverter import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.kyant.ocrcore.OcrService +import com.kyant.ocrcore.OcrWord +import com.kyant.pdfcore.converter.PdfConverter import com.kyant.pdfcore.model.PdfDocument +import com.kyant.pdfcore.raster.PdfRasterizer +import com.kyant.pdfcore.searchable.InvisibleWord +import com.kyant.pdfcore.searchable.PdfSearchableStamper +import com.kyant.pdfcore.viewer.PdfViewer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale data class ExtractTextUiState( val sourceFileName: String = "", + val sourceUri: Uri? = null, val isExtracting: Boolean = false, val text: String = "", val hasResult: Boolean = false, - val errorMessage: String? = null + val errorMessage: String? = null, + /** True once extraction found no digital text layer — offers the OCR fallback. */ + val canRecognize: Boolean = false, + val isRecognizing: Boolean = false, + val recognizeProgress: Pair? = null, + /** True once OCR has produced word boxes that can be baked into a searchable PDF. */ + val canMakeSearchable: Boolean = false, + val isSavingSearchable: Boolean = false, + val searchableOutputUri: Uri? = null, + val searchableSavedLabel: String? = null ) -class ExtractTextViewModel(private val converter: PdfConverter) : ViewModel() { +class ExtractTextViewModel( + private val converter: PdfConverter, + private val ocrService: OcrService, + private val pdfViewer: PdfViewer +) : ViewModel() { private val _uiState = MutableStateFlow(ExtractTextUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Word boxes from the last OCR pass, kept so "Make Searchable" doesn't need to re-run OCR. */ + private var lastOcrWordsByPage: Map> = emptyMap() + fun onSelectFile(context: Context, uri: Uri) { + lastOcrWordsByPage = emptyMap() _uiState.value = ExtractTextUiState(isExtracting = true) viewModelScope.launch { try { @@ -37,14 +72,15 @@ class ExtractTextViewModel(private val converter: PdfConverter) : ViewModel() { val name = queryFileName(context, uri) ?: "Unknown.pdf" val source = PdfDocument(uri = uri, name = name) val extracted = withContext(Dispatchers.IO) { converter.extractText(context, source) }.trim() + val empty = extracted.isEmpty() _uiState.value = ExtractTextUiState( sourceFileName = name, + sourceUri = uri, isExtracting = false, text = extracted, hasResult = true, - errorMessage = if (extracted.isEmpty()) - context.getString(R.string.extract_no_selectable_text) - else null + canRecognize = empty, + errorMessage = if (empty) context.getString(R.string.extract_no_selectable_text) else null ) } catch (e: Exception) { _uiState.value = ExtractTextUiState(isExtracting = false, errorMessage = context.getString(R.string.text_extraction_failed)) @@ -52,7 +88,92 @@ class ExtractTextViewModel(private val converter: PdfConverter) : ViewModel() { } } + /** + * Fallback for scanned/image-only PDFs: runs on-device OCR page by page (ML Kit, + * falling back to Tesseract4Android) and joins the recognized text, closing the + * "no selectable text" dead end left by pure PdfBox extraction. + */ + fun recognizeText(context: Context) { + val uri = _uiState.value.sourceUri ?: return + if (_uiState.value.isRecognizing) return + _uiState.value = _uiState.value.copy(isRecognizing = true, recognizeProgress = null) + viewModelScope.launch { + val wordsByPage = mutableMapOf>() + val result = withContext(Dispatchers.IO) { + val doc = runCatching { pdfViewer.open(context, uri) }.getOrNull() + val pageCount = doc?.pageCount?.takeIf { it > 0 } ?: 0 + doc?.let { runCatching { pdfViewer.close(it) } } + if (pageCount == 0) return@withContext "" + + val pages = mutableListOf() + for (i in 0 until pageCount) { + // MutableStateFlow.value is safe to set from any thread. + _uiState.value = _uiState.value.copy(recognizeProgress = i to pageCount) + val bitmap = runCatching { PdfRasterizer.rasterizePageBitmap(context, uri, i) }.getOrNull() + if (bitmap != null) { + val words = runCatching { ocrService.recognize(context, bitmap) }.getOrNull()?.words.orEmpty() + if (words.isNotEmpty()) { + wordsByPage[i] = words + pages.add(words.joinToString(" ") { it.text }) + } + if (!bitmap.isRecycled) bitmap.recycle() + } + } + pages.joinToString("\n\n") + } + lastOcrWordsByPage = wordsByPage + val trimmed = result.trim() + _uiState.value = _uiState.value.copy( + isRecognizing = false, + recognizeProgress = null, + text = trimmed, + hasResult = true, + canRecognize = false, + canMakeSearchable = wordsByPage.isNotEmpty(), + errorMessage = if (trimmed.isEmpty()) context.getString(R.string.extract_no_selectable_text) else null + ) + } + } + + /** Bakes the last OCR pass's word boxes into a copy of the source PDF as an invisible, + * selectable/searchable text layer — readable in any PDF viewer, not just ClearPDF. */ + fun makeSearchablePdf(context: Context) { + val uri = _uiState.value.sourceUri ?: return + val wordsByPage = lastOcrWordsByPage + if (wordsByPage.isEmpty() || _uiState.value.isSavingSearchable) return + _uiState.value = _uiState.value.copy(isSavingSearchable = true, errorMessage = null, searchableOutputUri = null) + viewModelScope.launch { + try { + val invisibleWordsByPage = wordsByPage.mapValues { (_, words) -> + words.map { InvisibleWord(it.text, it.left, it.top, it.right, it.bottom) } + } + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val fileName = "ClearPDF_Searchable_$ts.pdf" + val saveLabel = SaveLocationManager.getSavePathDisplay(context) + val outUri = createOutputUri(context, fileName) + withContext(Dispatchers.IO) { + PdfSearchableStamper.stamp(context, uri, outUri, invisibleWordsByPage) + } + RecentFilesManager.addRecent( + context, + RecentFile(name = fileName, uriString = outUri.toString(), timestamp = System.currentTimeMillis(), sizeBytes = 0) + ) + _uiState.value = _uiState.value.copy( + isSavingSearchable = false, + searchableOutputUri = outUri, + searchableSavedLabel = saveLabel + ) + } catch (t: Throwable) { + _uiState.value = _uiState.value.copy( + isSavingSearchable = false, + errorMessage = t.message ?: "Couldn't create a searchable PDF" + ) + } + } + } + fun reset() { + lastOcrWordsByPage = emptyMap() _uiState.value = ExtractTextUiState() } @@ -64,4 +185,33 @@ class ExtractTextViewModel(private val converter: PdfConverter) : ViewModel() { } else null } } catch (_: Exception) { null } + + private fun createOutputUri(context: Context, fileName: String): Uri { + val customUri = SaveLocationManager.getSaveUri(context) + if (customUri != null) { + return try { + val docUri = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, customUri) + docUri?.createFile("application/pdf", fileName)?.uri ?: createDownloadUri(context, fileName) + } catch (_: Exception) { createDownloadUri(context, fileName) } + } + return createDownloadUri(context, fileName) + } + + private fun createDownloadUri(context: Context, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cv = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IllegalStateException("Unable to create output in Downloads") + } else { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + if (!dir.exists()) dir.mkdirs() + val file = java.io.File(dir, fileName) + if (!file.exists()) file.createNewFile() + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/OcrPageCache.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/OcrPageCache.kt new file mode 100644 index 0000000..587c9de --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/OcrPageCache.kt @@ -0,0 +1,129 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.Context +import com.kyant.ocrcore.OcrWord +import com.kyant.pdfcore.model.PdfDocument +import java.io.File +import java.security.MessageDigest +import kotlin.math.abs +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Disk cache for on-device OCR results, keyed by document identity + page index. + * OCR (especially the Tesseract fallback) is too slow to re-run every time a scanned + * PDF is reopened, so a successful recognition is persisted once under the app cache dir. + */ +internal object OcrPageCache { + private val json = Json { ignoreUnknownKeys = true } + + private fun docKey(doc: PdfDocument): String { + val raw = "${doc.uri}|${doc.sizeBytes}" + val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } + + private fun cacheFile(context: Context, doc: PdfDocument, pageIndex: Int): File { + val dir = File(context.cacheDir, "ocr_cache/${docKey(doc)}").apply { mkdirs() } + return File(dir, "page_$pageIndex.json") + } + + fun read(context: Context, doc: PdfDocument, pageIndex: Int): List? { + val file = cacheFile(context, doc, pageIndex) + if (!file.exists()) return null + return runCatching { + json.decodeFromString(file.readText()).blocks.map { it.toOcrBlock() } + }.getOrNull() + } + + fun write(context: Context, doc: PdfDocument, pageIndex: Int, blocks: List) { + runCatching { + val page = CachedOcrPage(blocks.map { it.toCached() }) + cacheFile(context, doc, pageIndex).writeText(json.encodeToString(CachedOcrPage.serializer(), page)) + } + } +} + +@Serializable +private data class CachedOcrPage(val blocks: List) + +@Serializable +private data class CachedOcrBlock( + val id: String, + val text: String, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val charLefts: List, + val charRights: List +) + +private fun OcrTextBlock.toCached() = CachedOcrBlock( + id = id, text = text, left = left, top = top, right = right, bottom = bottom, + charLefts = charLefts.toList(), charRights = charRights.toList() +) + +private fun CachedOcrBlock.toOcrBlock() = OcrTextBlock( + id = id, text = text, left = left, top = top, right = right, bottom = bottom, + charLefts = charLefts.toFloatArray(), charRights = charRights.toFloatArray() +) + +/** + * Groups flat OCR word boxes (from [com.kyant.ocrcore.OcrService]) back into line-shaped + * [OcrTextBlock]s, mirroring [com.kyant.pdfcore.text.PdfTextService]'s character-stream + * grouping so the rest of the viewer (selection sweep, search, highlight) is fully generic + * over the two text sources and needs no OCR-specific branching. + */ +internal fun groupOcrWordsIntoBlocks(words: List, pageIndex: Int): List { + if (words.isEmpty()) return emptyList() + + val avgH = words.map { it.bottom - it.top }.average().toFloat().coerceAtLeast(0.005f) + val lineGap = avgH * 0.6f + + val sorted = words.sortedBy { (it.top + it.bottom) / 2f } + val lines = mutableListOf>() + for (w in sorted) { + val cy = (w.top + w.bottom) / 2f + val last = lines.lastOrNull() + val lastCy = last?.let { l -> l.map { (it.top + it.bottom) / 2f }.average().toFloat() } + if (last == null || lastCy == null || abs(cy - lastCy) > lineGap) { + lines.add(mutableListOf(w)) + } else { + last.add(w) + } + } + + return lines.mapIndexedNotNull { lineIdx, lineWords -> + val byX = lineWords.sortedBy { it.left } + val sb = StringBuilder() + val cl = ArrayList() + val cr = ArrayList() + var lastRight = -1f + byX.forEach { w -> + if (lastRight >= 0f) { + // Words are already tokenized by the OCR engine — always separate them with + // a space, unlike the PDFBox char-stream path which must infer word breaks. + sb.append(' '); cl.add(lastRight); cr.add(w.left) + } + val n = w.text.length.coerceAtLeast(1) + for (i in w.text.indices) { + sb.append(w.text[i]) + cl.add(w.left + (w.right - w.left) * i / n) + cr.add(w.left + (w.right - w.left) * (i + 1) / n) + } + lastRight = w.right + } + if (sb.isEmpty()) return@mapIndexedNotNull null + OcrTextBlock( + id = "$pageIndex-ocr-$lineIdx", + text = sb.toString(), + left = byX.minOf { it.left }, + top = byX.minOf { it.top }, + right = byX.maxOf { it.right }, + bottom = byX.maxOf { it.bottom }, + charLefts = cl.toFloatArray(), + charRights = cr.toFloatArray() + ) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt index 4c8e138..efa10bf 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.chethan616.clearpdf.R import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager +import com.chethan616.clearpdf.data.repository.LocalDocumentMirror import com.chethan616.clearpdf.data.repository.PdfServiceLocator import com.chethan616.clearpdf.data.repository.RecentFile import com.chethan616.clearpdf.data.repository.RecentFilesManager @@ -25,6 +26,7 @@ import com.chethan616.clearpdf.ui.utils.AppDispatchers import com.chethan616.clearpdf.ui.utils.StarPromptEventBus import com.chethan616.clearpdf.utils.UniversalDocumentConverter import com.kyant.pdfcore.model.PdfDocument +import com.kyant.pdfcore.raster.PdfRasterizer import com.kyant.pdfcore.security.PdfSecurityService import com.kyant.pdfcore.text.PdfTextBlock import kotlinx.coroutines.Dispatchers @@ -185,7 +187,19 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel companion object { private const val DEFAULT_RENDER_WIDTH = 1200 private const val MIN_RENDER_WIDTH = 720 - private const val CACHE_RADIUS = 2 + // Was 2 (5 pages held at once). A landscape page (any converted .pptx) is ~40% the bitmap + // memory of a portrait one at the same render width, so this is roughly the old radius-2 + // memory budget for a slide deck, and a moderate increase for a portrait document — in + // exchange for needing to re-render a page from scratch (the visible black-flash-then-redraw) + // far less often while scrolling either direction. + private const val CACHE_RADIUS = 5 + // How many pages ahead of the current one to render proactively, independent of whether the + // LazyColumn has actually composed that item yet. Without this, a page's first-ever render + // only starts once it scrolls into (near) view, which is exactly the "buffers while scrolling" + // complaint — the render and the need for it were racing. Warming pages before you reach them + // gives that race a head start. + private const val PREFETCH_AHEAD = 2 + private const val PREFETCH_BEHIND = 1 } fun openPdf(context: Context, uri: Uri, password: String? = null) { @@ -215,23 +229,40 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel ) } catch (_: Exception) {} + // `uri` may be a share-intent grant that is about to be revoked (many senders never + // attach a persistable flag, so the line above throws and is swallowed above) — this + // is what turns "opened once from WhatsApp/Gmail" into "Failed to open PDF." the next + // time it's tapped from Recents. `readableUri` is the original when it's still good, + // or a durable local copy this app saved the first time it *was* good. `uri` itself + // stays the identity used for naming and for the Recents entry. + val readableUri = withContext(Dispatchers.IO) { + LocalDocumentMirror.resolve(context, uri, extensionOf(context, uri)) + } + val sourceUri = withContext(Dispatchers.IO) { when { password != null -> { - val decrypted = PdfSecurityService.decryptToCache(context, uri, password) + val decrypted = PdfSecurityService.decryptToCache(context, readableUri, password) FileProvider.getUriForFile(context, "${context.packageName}.provider", decrypted) } - UniversalDocumentConverter.isPdf(context, uri) && - PdfSecurityService.isPasswordProtected(context, uri) -> { + UniversalDocumentConverter.isPdf(context, readableUri) && + PdfSecurityService.isPasswordProtected(context, readableUri) -> { throw PdfSecurityService.PasswordRequiredException() } - else -> uri + else -> readableUri } } val (doc, _) = withContext(Dispatchers.IO) { openDocumentWithFallback(context, sourceUri) } - val displayName = queryFileName(context, uri) ?: doc.name + // A revoked share-intent grant fails the DISPLAY_NAME query on `uri` exactly the way + // it fails a read — falling straight to `doc.name` would then show the mirror's own + // ".pdf" filename. The name this document was already saved under in Recents + // (set the first time it opened, when the query still worked) is what a returning + // "Failed to open" file should still show. + val displayName = queryFileName(context, uri) + ?: RecentFilesManager.getRecents(context).firstOrNull { it.uriString == uri.toString() }?.name + ?: doc.name _uiState.value = _uiState.value.copy( fileName = displayName, pageCount = doc.pageCount, @@ -380,20 +411,20 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel if (currentState.document?.uri != documentUri) return@launch if (pageIndex !in currentState.pageBitmaps.indices) return@launch + // Cache trimming does NOT happen here any more — it used to, sweeping every page + // outside `CACHE_RADIUS` of `currentState.currentPage` on every single render + // completion. `currentPage` updates on every scroll tick (undebounced, by design — + // other things need it live), and during a fast scroll a render completes every few + // milliseconds, so this ran constantly and evicted pages that were still transiting + // through the viewport a frame later. That eviction is now solely `trimBitmapCache` + // (below), which the caller in `PdfViewerScreen` debounces — this function's only job + // is to place the bitmap it just rendered. val bitmaps = currentState.pageBitmaps.toMutableList() val previous = bitmaps[pageIndex] if (previous != null && previous != bitmap && !previous.isRecycled) previous.recycle() bitmaps[pageIndex] = bitmap if (bitmap == null) renderedPageWidths.remove(pageIndex) else renderedPageWidths[pageIndex] = renderWidth - - bitmaps.forEachIndexed { index, existing -> - if (existing != null && index != pageIndex && abs(index - currentState.currentPage) > CACHE_RADIUS) { - if (!existing.isRecycled) existing.recycle() - bitmaps[index] = null - renderedPageWidths.remove(index) - } - } _uiState.value = currentState.copy(pageBitmaps = bitmaps) // Load text for page if not already done (async, IO thread) @@ -415,13 +446,20 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel ) viewModelScope.launch { - val blocks = withContext(Dispatchers.IO) { + val blocks = withContext(AppDispatchers.pdf) { // One full-document PdfBox parse at a time — see [textExtractionMutex]. - textExtractionMutex.withLock { + val pdfBlocks = textExtractionMutex.withLock { runCatching { textService.extractPage(context, doc.uri, pageIndex) }.getOrElse { emptyList() } } + if (pdfBlocks.isNotEmpty()) { + pdfBlocks.map { it.toOcrBlock() } + } else { + // No digital text layer (scanned/image-only page) — fall back to + // on-device OCR, cached to disk so re-opening the doc is instant. + loadOcrFallbackBlocks(context, doc, pageIndex) + } } val current = _uiState.value if (current.document?.uri != doc.uri) { @@ -429,7 +467,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel return@launch } val updated = current.ocrBlocksByPage.toMutableMap() - updated[pageIndex] = blocks.map { it.toOcrBlock() } + updated[pageIndex] = blocks textLoadingPages.remove(pageIndex) _uiState.value = current.copy( ocrBlocksByPage = updated, @@ -438,19 +476,68 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel } } + /** Rasterizes [pageIndex] and runs it through [PdfServiceLocator.ocrService], reading/writing the disk cache. */ + private suspend fun loadOcrFallbackBlocks(context: Context, doc: PdfDocument, pageIndex: Int): List { + OcrPageCache.read(context, doc, pageIndex)?.let { return it } + val bitmap = runCatching { + PdfRasterizer.rasterizePageBitmap(context, doc.uri, pageIndex) + }.getOrNull() ?: return emptyList() + return try { + val result = runCatching { PdfServiceLocator.ocrService.recognize(context, bitmap) }.getOrNull() + ?: return emptyList() + val blocks = groupOcrWordsIntoBlocks(result.words, pageIndex) + OcrPageCache.write(context, doc, pageIndex, blocks) + blocks + } finally { + if (!bitmap.isRecycled) bitmap.recycle() + } + } + + /** Cheap, called on every scroll tick: just records which page is "current" now. */ fun onPageChanged(page: Int) { + val state = _uiState.value + if (page !in state.pageBitmaps.indices || state.currentPage == page) return + _uiState.value = state.copy(currentPage = page) + } + + /** + * Kicks off rendering for the pages just ahead of (and a little behind) [page], so they're + * likely already there by the time a scroll actually reaches them instead of starting the render + * only once the page scrolls into view. `renderPage` itself is cheap to call redundantly — it + * returns immediately for a page that's already rendered at this width or already in flight — so + * this can safely be called on every scroll tick without debouncing. + */ + fun prefetchAround(context: Context, page: Int, targetWidthPx: Int) { + val pageCount = _uiState.value.pageBitmaps.size + for (p in (page + 1)..(page + PREFETCH_AHEAD)) { + if (p in 0 until pageCount) renderPage(context, p, targetWidthPx) + } + for (p in (page - 1) downTo (page - PREFETCH_BEHIND)) { + if (p in 0 until pageCount) renderPage(context, p, targetWidthPx) + } + } + + /** + * Recycles bitmaps far from [page]. Deliberately a separate call from [onPageChanged] — the + * caller debounces this one, so a fast scroll doesn't evict a page that's still transiting + * through the viewport a frame later (see the call site's comment for why that showed up as + * visible flicker/re-render churn while scrolling). + */ + fun trimBitmapCache(page: Int) { val state = _uiState.value if (page !in state.pageBitmaps.indices) return val bitmaps = state.pageBitmaps.toMutableList() + var evicted = false bitmaps.forEachIndexed { index, existing -> if (existing != null && abs(index - page) > CACHE_RADIUS) { if (!existing.isRecycled) existing.recycle() bitmaps[index] = null renderedPageWidths.remove(index) + evicted = true } } - _uiState.value = state.copy(currentPage = page, pageBitmaps = bitmaps) + if (evicted) _uiState.value = _uiState.value.copy(pageBitmaps = bitmaps) } fun toggleOcrSelection(pageIndex: Int, blockId: String) { @@ -991,6 +1078,12 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel } } + /** Best-effort real file extension, so a mirrored copy still sniffs as the right format. */ + private fun extensionOf(context: Context, uri: Uri): String { + val name = queryFileName(context, uri) ?: uri.lastPathSegment.orEmpty() + return name.substringAfterLast('.', "pdf").lowercase() + } + private fun queryFileName(context: Context, uri: Uri): String? = runCatching { context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SpreadsheetViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SpreadsheetViewModel.kt index bb871ab..e474af0 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SpreadsheetViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SpreadsheetViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.LocalDocumentMirror import com.chethan616.clearpdf.data.repository.RecentFile import com.chethan616.clearpdf.data.repository.RecentFilesManager import com.chethan616.clearpdf.utils.SpreadsheetParser @@ -43,14 +44,26 @@ class SpreadsheetViewModel : ViewModel() { if (started) return started = true viewModelScope.launch { + runCatching { + context.contentResolver.takePersistableUriPermission(uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } val name = withContext(Dispatchers.IO) { runCatching { queryName(context, uri) }.getOrNull() + ?: RecentFilesManager.getRecents(context).firstOrNull { it.uriString == uri.toString() }?.name ?: uri.lastPathSegment ?: "Spreadsheet" } val (sheets, localUri) = withContext(Dispatchers.IO) { - // Mirror to app cache so the file stays readable when reopened from recents (a picked - // content:// URI may lose permission later), then parse from the local copy. - val local = runCatching { mirrorToCache(context, uri, name) }.getOrDefault(uri) + // `uri` may be a share-intent grant on the way to being revoked — see + // `LocalDocumentMirror`'s doc comment for why that turns "opened once" into + // "Couldn't read this spreadsheet." the next time it's tapped from Recents. + // `readable` is `uri` itself when still good, or a durable local copy saved the + // first time it was. + val extension = name.substringAfterLast('.', "xlsx").lowercase() + val readable = LocalDocumentMirror.resolve(context, uri, extension) + // Mirror to app cache too, purely so the working copy for THIS session survives a + // revoked grant appearing mid-session (e.g. the sender's task finishes while the + // sheet is open) without touching `readable` again. + val local = runCatching { mirrorToCache(context, readable, name) }.getOrDefault(readable) SpreadsheetParser.parse(context, local) to local } _state.value = if (sheets.isEmpty()) { diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/DocxWebRenderer.kt b/app/src/main/java/com/chethan616/clearpdf/utils/DocxWebRenderer.kt new file mode 100644 index 0000000..38ffbdf --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/DocxWebRenderer.kt @@ -0,0 +1,329 @@ +package com.chethan616.clearpdf.utils + +import android.content.Context +import android.graphics.pdf.PdfDocument +import android.os.Bundle +import android.os.CancellationSignal +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.print.OpenLayoutResultCallback +import android.print.OpenWriteResultCallback +import android.print.PageRange +import android.print.PrintAttributes +import android.print.PrintDocumentAdapter +import android.print.PrintDocumentInfo +import android.util.Base64 +import android.util.Xml +import android.view.View +import android.webkit.WebView +import android.webkit.WebViewClient +import org.xmlpull.v1.XmlPullParser +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.zip.ZipInputStream + +/** + * Renders a .docx to PDF by laying it out with `docx-preview` in an offscreen WebView and driving + * the print framework over the result. + * + * Why this and not the hand-written reflow in [UniversalDocumentConverter]: that reflow re-lays + * Word content onto a fixed A4 page with its own margins and font, so it can only ever approximate + * the document. Real Word layout needs an engine. The engines that do it properly are enormous — + * `app.opendocument:odr-core-android` is a 100 MB AAR, and Apache POI's OOXML half is ~17 MB of + * jars that still only *parses*, leaving you to write the renderer anyway. `docx-preview` is + * Apache-2.0 and adds about 48 KB to the APK once compressed, because it delegates layout to a + * thing the phone already has: the browser engine. + * + * Offline: the page is loaded from `file:///android_asset`, network loads are blocked on the + * WebView, and the app declares no `INTERNET` permission at all — so the OS would refuse a network + * request even if a script attempted one. + * + * There are two ways out of the WebView, tried in order: + * 1. **The print framework.** It honours the CSS page breaks the host page puts on each section, + * so the author's own page breaks land on real PDF pages. Requires [OpenLayoutResultCallback], + * which relies on a package trick that could fail on some device. + * 2. **Slice rendering** — measure the content and draw page-height bands straight onto a + * `PdfDocument`, the way [com.chethan616.clearpdf.util.HtmlToPdfConverter] already does for the + * HTML tool. Pure public API, so it always works, but it cuts every N pixels regardless of + * where a break belongs. Still carries all of docx-preview's layout — fonts, tables, headers, + * real margins — so it is far closer to Word than the fallback below it. + * + * Every failure path returns false and leaves the caller to fall back to the hand-written reflow. + * Nothing here is allowed to make a document that previously opened stop opening. + */ +internal object DocxWebRenderer { + + /** Generous: the WebView has to cold-start, parse the package, and lay out every page. */ + private const val TimeoutSeconds = 90L + + /** CSS pixels per inch, which is what a WebView lays `in`/`pt` lengths out against. */ + private const val CssDpi = 96 + + private const val MaxSlicedPages = 500 + + /** + * Whether the print route can be used at all, decided once by actually constructing one of the + * callbacks. If the package trick does not hold on this device the constructor throws an + * access/verification error here, at a point where the only consequence is choosing the other + * route — rather than half-way through writing a file. + */ + private val printRouteAvailable: Boolean by lazy { + runCatching { + object : OpenLayoutResultCallback() { + override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) = Unit + override fun onLayoutFailed(error: CharSequence?) = Unit + override fun onLayoutCancelled() = Unit + } + true + }.getOrDefault(false) + } + + /** A4 in mils (thousandths of an inch), for a document that declares no page size. */ + private const val A4WidthMils = 8268 + private const val A4HeightMils = 11693 + + /** + * @return true only if [outFile] now holds a non-empty PDF. False means "use the fallback" — + * it is never an error the user should see. + */ + fun render(context: Context, docxBytes: ByteArray, outFile: File): Boolean { + // The whole flow blocks on a latch that only the main thread can release, so being called + // *from* the main thread would deadlock outright. The converter runs on IO today; this is + // here so that stays true by construction rather than by memory. + if (Looper.myLooper() == Looper.getMainLooper()) return false + + val (widthMils, heightMils) = readPageSizeMils(docxBytes) + val latch = CountDownLatch(1) + val succeeded = AtomicBoolean(false) + val main = Handler(Looper.getMainLooper()) + var webView: WebView? = null + + val finish = { ok: Boolean -> + succeeded.set(ok) + latch.countDown() + } + + main.post { + runCatching { + val view = WebView(context) + webView = view + view.settings.javaScriptEnabled = true + // Redundant with the missing INTERNET permission, and kept anyway: two independent + // guarantees that a document can never phone home. + view.settings.blockNetworkLoads = true + view.settings.allowFileAccess = true + + view.addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun onRendered() { + // JavascriptInterface callbacks arrive on the WebView's JS bridge + // thread; everything below touches the view and must be on main. + main.post { + // Slicing is the fallback for the print route failing *and* for it + // being unavailable, so both paths converge on the same retry. + val slice = { + finish( + runCatching { sliceToPdf(view, widthMils, heightMils, outFile) } + .getOrDefault(false) + ) + } + if (!printRouteAvailable) { + slice() + } else { + runCatching { + printToPdf(view, widthMils, heightMils, outFile) { ok -> + if (ok) finish(true) else slice() + } + }.onFailure { slice() } + } + } + } + + @android.webkit.JavascriptInterface + @Suppress("UNUSED_PARAMETER") + fun onFailed(reason: String) = finish(false) + }, + "AndroidDocx" + ) + + view.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + val base64 = Base64.encodeToString(docxBytes, Base64.NO_WRAP) + view.evaluateJavascript("renderDocx('$base64')", null) + } + } + view.loadUrl("file:///android_asset/docx/index.html") + }.onFailure { finish(false) } + } + + val completed = runCatching { latch.await(TimeoutSeconds, TimeUnit.SECONDS) }.getOrDefault(false) + main.post { runCatching { webView?.destroy() } } + + if (!completed || !succeeded.get()) { + runCatching { if (outFile.exists()) outFile.delete() } + return false + } + if (outFile.length() <= 0L) { + runCatching { outFile.delete() } + return false + } + return true + } + + /** + * Drives the adapter's `onLayout` → `onWrite` → `onFinish` sequence by hand, which is what the + * system print dialog would otherwise do for us. Main thread only. + */ + private fun printToPdf( + view: WebView, + widthMils: Int, + heightMils: Int, + outFile: File, + done: (Boolean) -> Unit + ) { + outFile.parentFile?.mkdirs() + val adapter: PrintDocumentAdapter = view.createPrintDocumentAdapter(outFile.nameWithoutExtension) + val attributes = PrintAttributes.Builder() + .setMediaSize(PrintAttributes.MediaSize("docx", "docx", widthMils, heightMils)) + .setResolution(PrintAttributes.Resolution("pdf", "pdf", 300, 300)) + // The document's own margins are already page padding in the HTML, so any margin here + // would be applied a second time and shrink every page's content. + .setMinMargins(PrintAttributes.Margins.NO_MARGINS) + .build() + + var descriptor: ParcelFileDescriptor? = null + val cleanUp = { ok: Boolean -> + runCatching { descriptor?.close() } + runCatching { adapter.onFinish() } + done(ok) + } + + adapter.onStart() + adapter.onLayout( + null, + attributes, + CancellationSignal(), + object : OpenLayoutResultCallback() { + override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) { + runCatching { + descriptor = ParcelFileDescriptor.open( + outFile, + ParcelFileDescriptor.MODE_READ_WRITE or + ParcelFileDescriptor.MODE_CREATE or + ParcelFileDescriptor.MODE_TRUNCATE + ) + adapter.onWrite( + arrayOf(PageRange.ALL_PAGES), + descriptor, + CancellationSignal(), + object : OpenWriteResultCallback() { + override fun onWriteFinished(pages: Array?) = cleanUp(true) + override fun onWriteFailed(error: CharSequence?) = cleanUp(false) + override fun onWriteCancelled() = cleanUp(false) + } + ) + }.onFailure { cleanUp(false) } + } + + override fun onLayoutFailed(error: CharSequence?) = cleanUp(false) + override fun onLayoutCancelled() = cleanUp(false) + }, + Bundle() + ) + } + + /** + * Draws the laid-out page as page-height bands onto a [PdfDocument], the same technique + * [com.chethan616.clearpdf.util.HtmlToPdfConverter] uses for the HTML tool. + * + * Public API throughout, so this always works — but it cuts strictly every page height, with no + * notion of where a break belongs, so a band boundary can fall through a line of text. That is + * why it is second choice rather than the only implementation. + */ + private fun sliceToPdf(view: WebView, widthMils: Int, heightMils: Int, outFile: File): Boolean { + val pageWidth = (widthMils.toLong() * CssDpi / 1000L).toInt().coerceIn(80, 5000) + val pageHeight = (heightMils.toLong() * CssDpi / 1000L).toInt().coerceIn(80, 5000) + + view.measure( + View.MeasureSpec.makeMeasureSpec(pageWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + val contentHeight = view.measuredHeight.coerceAtLeast(1) + view.layout(0, 0, pageWidth, contentHeight) + + outFile.parentFile?.mkdirs() + val document = PdfDocument() + try { + var y = 0 + var pageNumber = 1 + while (y < contentHeight && pageNumber <= MaxSlicedPages) { + val page = document.startPage( + PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create() + ) + page.canvas.save() + page.canvas.translate(0f, -y.toFloat()) + view.draw(page.canvas) + page.canvas.restore() + document.finishPage(page) + y += pageHeight + pageNumber++ + } + FileOutputStream(outFile).use { document.writeTo(it) } + } finally { + document.close() + } + return outFile.length() > 0 + } + + /** + * The document's own page size from ``, so the PDF page matches what the + * author set rather than defaulting everything to A4 — a US Letter document rendered onto A4 + * reflows every line. + * + * Word stores these in twips (1/1440 inch); the print framework wants mils (1/1000 inch). + */ + private fun readPageSizeMils(docxBytes: ByteArray): Pair { + val documentXml = runCatching { + ZipInputStream(docxBytes.inputStream()).use { zip -> + var found: ByteArray? = null + while (true) { + val entry = zip.nextEntry ?: break + if (entry.name.removePrefix("/") == "word/document.xml") { + found = zip.readBytes() + break + } + } + found + } + }.getOrNull() ?: return A4WidthMils to A4HeightMils + + return runCatching { + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(documentXml.inputStream(), "UTF-8") + } + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "w:pgSz") { + val w = parser.getAttributeValue(null, "w:w")?.toIntOrNull() + val h = parser.getAttributeValue(null, "w:h")?.toIntOrNull() + if (w != null && h != null && w > 0 && h > 0) { + // `w:orient` is advisory — Word already swaps w/h for landscape sections, + // so trusting the numbers is both simpler and more reliable. + return@runCatching twipsToMils(w) to twipsToMils(h) + } + } + event = parser.next() + } + A4WidthMils to A4HeightMils + }.getOrDefault(A4WidthMils to A4HeightMils) + } + + private fun twipsToMils(twips: Int): Int = + (twips * 1000L / 1440L).toInt().coerceIn(1000, 40000) +} diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/ExcelCellFormat.kt b/app/src/main/java/com/chethan616/clearpdf/utils/ExcelCellFormat.kt new file mode 100644 index 0000000..a9ea26f --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/ExcelCellFormat.kt @@ -0,0 +1,286 @@ +package com.chethan616.clearpdf.utils + +import java.text.DecimalFormat +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.Locale +import java.util.TimeZone + +/** + * Applies a workbook's number formats to raw cell values. + * + * Everything in an .xlsx sheet is stored unformatted: a date is a plain `46267` and the only + * thing that makes it a date is the `numFmtId` its style points at. A reader that ignores styles — + * as this app's did — shows the serial, so `02-09-2026` reads as `46267` and the column looks like + * corrupt data rather than a date. This resolves the format code for a cell and renders the value + * the way Excel would. + * + * Deliberately hand-written: the alternative is pulling in POI's OOXML half (several MB of jars plus + * the XmlBeans/curvesapi transitive tail) to format one column, and the app already ships its own + * pull-parser for these files. + */ +internal object ExcelCellFormat { + + /** + * The built-in format ids. Excel never writes these into `styles.xml` — a cell just points at + * id 14 and every reader is expected to already know it means a short date. Ids not listed here + * are either locale/currency variants we render as plain numbers or genuinely unused. + */ + private val BuiltIn: Map = mapOf( + 0 to "General", 1 to "0", 2 to "0.00", 3 to "#,##0", 4 to "#,##0.00", + 9 to "0%", 10 to "0.00%", 11 to "0.00E+00", + 14 to "dd-mm-yyyy", 15 to "d-mmm-yy", 16 to "d-mmm", 17 to "mmm-yy", + 18 to "h:mm AM/PM", 19 to "h:mm:ss AM/PM", 20 to "h:mm", 21 to "h:mm:ss", + 22 to "dd-mm-yyyy h:mm", + 37 to "#,##0", 38 to "#,##0", 39 to "#,##0.00", 40 to "#,##0.00", + 45 to "mm:ss", 46 to "h:mm:ss", 47 to "mm:ss.0", + 48 to "##0.0E+0", 49 to "@" + ) + + /** `numFmtId` 14 is "short date" in the *user's* locale, so it gets the device's pattern. */ + private val ShortDatePattern: String by lazy { + runCatching { + val fmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT, Locale.getDefault()) + (fmt as? java.text.SimpleDateFormat)?.toPattern() + ?.replace('M', 'm') // Excel codes are lower-case; the renderer below expects that + ?.replace('E', 'd') + }.getOrNull()?.takeIf { it.isNotBlank() } ?: "dd-mm-yyyy" + } + + /** + * `cellXfs` index → format code. Built once per workbook from `xl/styles.xml`; an empty list + * (no styles part, or an unreadable one) simply means every value renders raw, which is the old + * behaviour and never worse than it. + */ + fun formatCodesFor(numFmtIdByStyle: List, customCodes: Map): List = + numFmtIdByStyle.map { id -> + customCodes[id] ?: if (id == 14) ShortDatePattern else (BuiltIn[id] ?: "General") + } + + /** + * Render [raw] (the literal `` text of a numeric cell) through [code]. + * + * Returns [raw] untouched for anything this doesn't understand — a blank/General/text format, a + * value that isn't a number, or a format code that fails to compile. A wrong-looking number is + * a bug; a *silently invented* one would be worse, so every uncertain path falls back to what + * the file literally says. + */ + fun apply(raw: String, code: String?): String { + if (code.isNullOrBlank() || code.equals("General", true) || code == "@") return raw + val value = raw.trim().toDoubleOrNull() ?: return raw + + // "positive;negative;zero;text" — pick the section that applies, then drop the colour and + // condition brackets (`[Red]`, `[<=100]`) that only affect styling we don't reproduce. + val sections = splitSections(code) + val section = when { + value < 0 && sections.size >= 2 -> sections[1] + value == 0.0 && sections.size >= 3 -> sections[2] + else -> sections[0] + } + val body = section.replace(Regex("\\[(?!h+]|hh+])[^]]*]", RegexOption.IGNORE_CASE), "") + if (body.isBlank()) return raw + + return runCatching { + if (isDateTime(body)) formatDateTime(value, body) else formatNumber(value, body) + }.getOrDefault(raw) + } + + /** Split on `;` while honouring quoted literals and backslash escapes. */ + private fun splitSections(code: String): List { + val out = mutableListOf() + val sb = StringBuilder() + var i = 0 + var quoted = false + while (i < code.length) { + val c = code[i] + when { + c == '\\' && i + 1 < code.length -> { sb.append(c).append(code[i + 1]); i++ } + c == '"' -> { quoted = !quoted; sb.append(c) } + c == ';' && !quoted -> { out.add(sb.toString()); sb.clear() } + else -> sb.append(c) + } + i++ + } + out.add(sb.toString()) + return out + } + + /** A format is a date/time one if any y/m/d/h/s appears outside quotes and escapes. */ + private fun isDateTime(code: String): Boolean { + var i = 0 + var quoted = false + while (i < code.length) { + val c = code[i] + when { + c == '\\' -> i++ + c == '"' -> quoted = !quoted + !quoted && c.lowercaseChar() in "ymdhs" -> return true + } + i++ + } + return false + } + + // ── Date / time ───────────────────────────────────────────────────────────── + + private val MonthsShort = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + private val MonthsLong = arrayOf("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December") + private val DaysShort = arrayOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") + private val DaysLong = arrayOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") + + private fun formatDateTime(serial: Double, code: String): String { + val cal = calendarFor(serial) + val tokens = tokenize(code) + val hasAmPm = tokens.any { it.kind == 'a' } + val sb = StringBuilder() + + for ((index, t) in tokens.withIndex()) { + when (t.kind) { + 'l' -> sb.append(t.literal) + 'y' -> { + val y = cal.get(Calendar.YEAR) + sb.append(if (t.run >= 3) y.toString() else (y % 100).toString().padStart(2, '0')) + } + 'm' -> { + // The one genuinely ambiguous code in the whole spec: `m` is a month unless it + // sits next to an hour or a second, where it means minutes. + if (isMinute(tokens, index)) { + sb.append(pad(cal.get(Calendar.MINUTE), t.run)) + } else when { + t.run >= 4 -> sb.append(MonthsLong[cal.get(Calendar.MONTH)]) + t.run == 3 -> sb.append(MonthsShort[cal.get(Calendar.MONTH)]) + else -> sb.append(pad(cal.get(Calendar.MONTH) + 1, t.run)) + } + } + 'd' -> when { + t.run >= 4 -> sb.append(DaysLong[cal.get(Calendar.DAY_OF_WEEK) - 1]) + t.run == 3 -> sb.append(DaysShort[cal.get(Calendar.DAY_OF_WEEK) - 1]) + else -> sb.append(pad(cal.get(Calendar.DAY_OF_MONTH), t.run)) + } + 'h' -> { + val h24 = cal.get(Calendar.HOUR_OF_DAY) + val h = if (hasAmPm) (h24 % 12).let { if (it == 0) 12 else it } else h24 + sb.append(pad(h, t.run)) + } + 's' -> sb.append(pad(cal.get(Calendar.SECOND), t.run)) + 'a' -> sb.append(if (cal.get(Calendar.AM_PM) == Calendar.AM) "AM" else "PM") + } + } + return sb.toString().trim() + } + + /** Excel's epoch, including the deliberate 1900 leap-year bug it inherited from Lotus 1-2-3. */ + private fun calendarFor(serial: Double): Calendar { + val days = Math.floor(serial).toInt() + // Serial 1 is 1900-01-01 and serial 60 is 1900-02-29 — a day that did not exist, which Excel + // still counts so that Lotus-era files keep working. Everything from serial 61 on is one day + // ahead of a real calendar, so 1899-12-30 is the epoch there; below the phantom day the + // epoch is effectively 1899-12-31. + // + // Serial 60 itself has no representable answer: no calendar has a 29 Feb 1900. It lands on + // 28 Feb 1900 here, which is what POI and most other readers also do, and it only ever comes + // up in a file that stores that impossible date. + val offset = if (days < 60) days + 1 else days + val cal = GregorianCalendar(TimeZone.getTimeZone("UTC"), Locale.US) + cal.clear() + cal.set(1899, Calendar.DECEMBER, 30, 0, 0, 0) + cal.add(Calendar.DATE, offset) + val secondsInDay = Math.round((serial - days) * 86400.0).toInt() + cal.add(Calendar.SECOND, secondsInDay) + return cal + } + + private fun pad(value: Int, run: Int): String = + if (run >= 2) value.toString().padStart(2, '0') else value.toString() + + private data class Token(val kind: Char, val run: Int = 1, val literal: String = "") + + private fun tokenize(code: String): List { + val out = mutableListOf() + var i = 0 + while (i < code.length) { + val c = code[i] + when { + c == '\\' && i + 1 < code.length -> { out.add(Token('l', literal = code[i + 1].toString())); i += 2 } + c == '"' -> { + val end = code.indexOf('"', i + 1) + val stop = if (end == -1) code.length else end + out.add(Token('l', literal = code.substring(i + 1, stop))) + i = stop + 1 + } + code.startsWith("AM/PM", i, ignoreCase = true) -> { out.add(Token('a')); i += 5 } + code.startsWith("A/P", i, ignoreCase = true) -> { out.add(Token('a')); i += 3 } + c == '[' -> { // `[h]` / `[mm]` elapsed-time brackets: treat as the bare code + val end = code.indexOf(']', i) + val stop = if (end == -1) code.length else end + val inner = code.substring(i + 1, stop) + if (inner.isNotEmpty() && inner[0].lowercaseChar() in "hms") { + out.add(Token(inner[0].lowercaseChar(), inner.length)) + } + i = stop + 1 + } + c.lowercaseChar() in "ymdhs" -> { + val kind = c.lowercaseChar() + var run = 0 + while (i < code.length && code[i].lowercaseChar() == kind) { run++; i++ } + out.add(Token(kind, run)) + } + else -> { out.add(Token('l', literal = c.toString())); i++ } + } + } + return out + } + + /** `m` is minutes when the nearest time-ish neighbour is an hour before it or a second after. */ + private fun isMinute(tokens: List, index: Int): Boolean { + for (i in index - 1 downTo 0) { + val k = tokens[i].kind + if (k == 'l') continue + if (k == 'h') return true + break + } + for (i in index + 1 until tokens.size) { + val k = tokens[i].kind + if (k == 'l') continue + if (k == 's') return true + break + } + return false + } + + // ── Numeric ───────────────────────────────────────────────────────────────── + + /** + * Excel and [DecimalFormat] share enough pattern syntax (`#`, `0`, `,`, `.`, `%`, `E`) that the + * cheapest correct thing is to hand the code straight over, once the Excel-only decorations are + * translated: `\x` and `"x"` literals become the `'x'` DecimalFormat spells them with. + */ + private fun formatNumber(value: Double, code: String): String { + val pattern = StringBuilder() + var i = 0 + while (i < code.length) { + val c = code[i] + when { + c == '\\' && i + 1 < code.length -> { pattern.append('\'').append(code[i + 1]).append('\''); i += 2 } + c == '"' -> { + val end = code.indexOf('"', i + 1) + val stop = if (end == -1) code.length else end + pattern.append('\'').append(code, i + 1, stop).append('\'') + i = stop + 1 + } + c == '_' -> i += 2 // "width of the next character" padding — no analogue + c == '*' -> i += 2 // fill-repeat — likewise + c == '?' -> { pattern.append('#'); i++ } + else -> { pattern.append(c); i++ } + } + } + val text = pattern.toString() + if (text.none { it == '#' || it == '0' }) return trimTrailingZeros(value) + return DecimalFormat(text).format(if (value < 0) -value else value).let { if (value < 0) "-$it" else it } + } + + /** `3.0` came out of a spreadsheet as an integer; show it as one. */ + private fun trimTrailingZeros(value: Double): String = + if (value == Math.floor(value) && !value.isInfinite()) value.toLong().toString() + else value.toString() +} diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/OoxmlNode.kt b/app/src/main/java/com/chethan616/clearpdf/utils/OoxmlNode.kt new file mode 100644 index 0000000..df97f1b --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/OoxmlNode.kt @@ -0,0 +1,82 @@ +package com.chethan616.clearpdf.utils + +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.InputStream + +/** + * A minimal in-memory tree for one OOXML part. + * + * The rest of this package parses with a streaming pull parser, which is right when the shape of + * the document matches the shape of the output — a .docx is a flat run of paragraphs, so it streams + * cleanly. A .pptx is not: a shape's position may live in a *different part* (its layout, or the + * master behind that), a group transform has to be applied to children parsed later, and a run's + * colour depends on a theme part read before any slide. Expressing that as pull-parser state is + * where the old presentation converter gave up and just concatenated every text node it saw. + * + * Slide parts are tens of kilobytes, so materialising them is cheap; the streaming reader stays in + * use for the parts that are genuinely large (worksheets, shared strings). + */ +internal class OoxmlNode(val name: String, val attrs: Map) { + + val children = mutableListOf() + var text: String = "" + + fun attr(key: String): String? = attrs[key] + + /** Direct child by tag name. */ + fun child(tag: String): OoxmlNode? = children.firstOrNull { it.name == tag } + + /** Direct children by tag name. */ + fun childrenNamed(tag: String): List = children.filter { it.name == tag } + + /** First descendant by tag name, depth-first, this node included. */ + fun find(tag: String): OoxmlNode? { + if (name == tag) return this + for (c in children) c.find(tag)?.let { return it } + return null + } + + /** All text under this node, in document order — the flattened value of a `` run group. */ + fun textContent(): String { + if (children.isEmpty()) return text + val sb = StringBuilder(text) + for (c in children) sb.append(c.textContent()) + return sb.toString() + } + + companion object { + /** + * Namespace prefixes are kept as written (`a:off`, `p:sp`), matching the rest of this + * package: OOXML producers are consistent about them in practice, and turning namespace + * processing on costs a URI lookup per element for no gain here. + */ + fun parse(stream: InputStream): OoxmlNode? = runCatching { + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(stream, "UTF-8") + } + var root: OoxmlNode? = null + val stack = ArrayDeque() + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> { + val attrs = HashMap(parser.attributeCount) + for (i in 0 until parser.attributeCount) { + attrs[parser.getAttributeName(i)] = parser.getAttributeValue(i) + } + val node = OoxmlNode(parser.name, attrs) + stack.lastOrNull()?.children?.add(node) + if (root == null) root = node + stack.addLast(node) + } + XmlPullParser.TEXT -> stack.lastOrNull()?.let { it.text += parser.text } + XmlPullParser.END_TAG -> stack.removeLastOrNull() + } + event = parser.next() + } + root + }.getOrNull() + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/PptxRenderer.kt b/app/src/main/java/com/chethan616/clearpdf/utils/PptxRenderer.kt new file mode 100644 index 0000000..7dafdb5 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/PptxRenderer.kt @@ -0,0 +1,628 @@ +package com.chethan616.clearpdf.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.TypefaceSpan +import android.text.style.UnderlineSpan +import java.util.zip.ZipInputStream + +/** + * Renders a .pptx to one PDF page per slide, at the presentation's real slide size, with shapes + * drawn where the author put them. + * + * The previous converter walked every `` in the package and emitted each one as a paragraph on + * a portrait A4 page. That is not a presentation viewer — a slide's meaning is largely carried by + * its geometry (what is a title, what sits beside what, what is a picture), and flattening it to a + * column of strings destroys all of it. Text also arrived in document order, which for a slide with + * overlapping placeholders is not reading order. + * + * This reads what a slide actually is: the slide size from `ppt/presentation.xml`, the shape tree + * from each slide part, positions inherited from the slide layout and master when a placeholder + * doesn't carry its own, colours resolved through the theme, and pictures from `ppt/media`. + * + * Deliberately dependency-free. Every library that renders PowerPoint properly on Android (POI's + * OOXML half plus XmlBeans, or a bundled LibreOffice/UNO core) is measured in tens of megabytes, + * and this app's whole install is smaller than that. The trade-off is stated in the class: this is + * a faithful-enough static render, not PowerPoint. Charts, SmartArt, 3-D effects, gradients and + * animations are not reproduced. + */ +internal object PptxRenderer { + + /** English Metric Units per point: 914400 EMU per inch ÷ 72 points per inch. */ + private const val EmuPerPoint = 12700f + + /** 4:3 at 720×540 pt — what PowerPoint used before 16:9, and a safe size for a broken header. */ + private const val DefaultSlideW = 720f + private const val DefaultSlideH = 540f + + private const val MaxSlides = 500 + + /** + * @return a rendered document, or null if this isn't a presentation we can read — the caller + * falls back to its text-dump path rather than showing an empty file. + */ + fun render(bytes: ByteArray): PdfDocument? { + val pkg = readPackage(bytes) ?: return null + val slides = pkg.slidePaths() + if (slides.isEmpty()) return null + + val doc = PdfDocument() + val (slideW, slideH) = pkg.slideSize() + var pageNumber = 1 + for (path in slides.take(MaxSlides)) { + val page = doc.startPage( + PdfDocument.PageInfo.Builder(Math.round(slideW), Math.round(slideH), pageNumber).create() + ) + runCatching { pkg.drawSlide(page.canvas, path, pageNumber) } + doc.finishPage(page) + pageNumber++ + } + return doc + } + + // ── Package ───────────────────────────────────────────────────────────────── + + private fun readPackage(bytes: ByteArray): Package? = runCatching { + val entries = HashMap() + ZipInputStream(bytes.inputStream()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + val n = entry.name.removePrefix("/") + // Everything a slide can reach. Notes and thumbnails are skipped: they are not on + // the rendered page and a notes-heavy deck can carry more of them than slides. + val keep = n == "ppt/presentation.xml" || + n == "ppt/_rels/presentation.xml.rels" || + n.startsWith("ppt/slides/") || + n.startsWith("ppt/slideLayouts/") || + n.startsWith("ppt/slideMasters/") || + n.startsWith("ppt/theme/") || + n.startsWith("ppt/media/") + if (keep && !n.startsWith("ppt/notesSlides/")) entries[n] = zip.readBytes() + } + } + if (entries["ppt/presentation.xml"] == null) null else Package(entries) + }.getOrNull() + + private class Package(val entries: Map) { + + private val parsed = HashMap() + private val relsCache = HashMap>() + + fun part(path: String): OoxmlNode? = parsed.getOrPut(path) { + entries[path]?.let { OoxmlNode.parse(it.inputStream()) } + } + + /** `rId` → absolute package path, for the `.rels` sidecar of [path]. */ + fun rels(path: String): Map = relsCache.getOrPut(path) { + val dir = path.substringBeforeLast('/', "") + val relPath = "$dir/_rels/${path.substringAfterLast('/')}.rels" + val root = entries[relPath]?.let { OoxmlNode.parse(it.inputStream()) } ?: return@getOrPut emptyMap() + root.childrenNamed("Relationship").mapNotNull { r -> + val id = r.attr("Id") ?: return@mapNotNull null + val target = r.attr("Target") ?: return@mapNotNull null + id to resolve(dir, target) + }.toMap() + } + + /** Resolve a relationship target (usually `../slideLayouts/slideLayout3.xml`) to a part path. */ + private fun resolve(baseDir: String, target: String): String { + if (target.startsWith("/")) return target.removePrefix("/") + val stack = ArrayDeque(baseDir.split('/').filter { it.isNotEmpty() }) + for (segment in target.split('/')) { + when (segment) { + "", "." -> {} + ".." -> stack.removeLastOrNull() + else -> stack.addLast(segment) + } + } + return stack.joinToString("/") + } + + fun slideSize(): Pair { + val sz = part("ppt/presentation.xml")?.find("p:sldSz") + val w = sz?.attr("cx")?.toFloatOrNull()?.div(EmuPerPoint) ?: DefaultSlideW + val h = sz?.attr("cy")?.toFloatOrNull()?.div(EmuPerPoint) ?: DefaultSlideH + return w.coerceIn(120f, 4000f) to h.coerceIn(120f, 4000f) + } + + /** + * Slides in presentation order. `` is the authoritative order — the zip's entry + * order is arbitrary, and `slide10.xml` sorts before `slide2.xml` as a string, so both of + * the obvious shortcuts get a real deck wrong. + */ + fun slidePaths(): List { + val presentation = part("ppt/presentation.xml") + val rels = rels("ppt/presentation.xml") + val listed = presentation?.find("p:sldIdLst")?.childrenNamed("p:sldId") + ?.mapNotNull { rels[it.attr("r:id")] } + ?.filter { entries.containsKey(it) } + .orEmpty() + if (listed.isNotEmpty()) return listed + return entries.keys + .filter { it.startsWith("ppt/slides/slide") && it.endsWith(".xml") } + .sortedBy { Regex("slide(\\d+)\\.xml").find(it)?.groupValues?.get(1)?.toIntOrNull() ?: Int.MAX_VALUE } + } + + // ── Theme colours ─────────────────────────────────────────────────────── + + /** Scheme name (`accent1`, `tx1`, …) → ARGB, from the master's theme. */ + private fun themeColors(masterPath: String?): Map { + val themePath = masterPath?.let { m -> rels(m).values.firstOrNull { it.startsWith("ppt/theme/") } } + ?: entries.keys.firstOrNull { it.startsWith("ppt/theme/theme") } + ?: return emptyMap() + val scheme = part(themePath)?.find("a:clrScheme") ?: return emptyMap() + val out = HashMap() + for (entry in scheme.children) { + // `` or `` + val key = entry.name.substringAfter(':') + val srgb = entry.child("a:srgbClr")?.attr("val") + ?: entry.child("a:sysClr")?.attr("lastClr") + val color = parseSrgb(srgb) ?: continue + out[key] = color + // PowerPoint's slide-level names are one indirection off the theme's own. + when (key) { + "dk1" -> out["tx1"] = color + "lt1" -> out["bg1"] = color + "dk2" -> out["tx2"] = color + "lt2" -> out["bg2"] = color + } + } + return out + } + + // ── Slide rendering ───────────────────────────────────────────────────── + + fun drawSlide(canvas: Canvas, slidePath: String, slideNumber: Int) { + val slide = part(slidePath) ?: return + val layoutPath = rels(slidePath).values.firstOrNull { it.startsWith("ppt/slideLayouts/") } + val masterPath = layoutPath?.let { rels(it).values.firstOrNull { p -> p.startsWith("ppt/slideMasters/") } } + val theme = themeColors(masterPath) + + val ctx = SlideContext( + theme = theme, + slidePath = slidePath, + slideNumber = slideNumber, + // A placeholder without its own `` inherits the layout's box, and the layout + // may in turn inherit the master's. Looking that up is what keeps titles and body + // text on the slide instead of stacked in the top-left corner. + placeholders = placeholderBoxes(masterPath) + placeholderBoxes(layoutPath) + ) + + canvas.drawColor( + background(slide, theme) + ?: layoutPath?.let { background(part(it), theme) } + ?: masterPath?.let { background(part(it), theme) } + ?: Color.WHITE + ) + + val tree = slide.find("p:spTree") ?: return + drawShapeTree(canvas, tree, ctx, identityTransform()) + } + + private fun background(part: OoxmlNode?, theme: Map): Int? { + val fill = part?.find("p:bg")?.find("a:solidFill") ?: return null + return solidFillColor(fill, theme) + } + + /** `"type|idx"` → box, from a layout or master part. */ + private fun placeholderBoxes(path: String?): Map { + val tree = path?.let { part(it) }?.find("p:spTree") ?: return emptyMap() + val out = HashMap() + for (shape in tree.childrenNamed("p:sp")) { + val ph = shape.find("p:ph") ?: continue + val box = shapeBox(shape) ?: continue + val type = ph.attr("type") ?: "body" + val idx = ph.attr("idx") ?: "" + out["$type|$idx"] = box + // `putIfAbsent` is API 24 and this app ships to 23; the exact-match key above wins + // over these looser fallbacks, so first-writer-wins is the behaviour we want anyway. + if (!out.containsKey("$type|")) out["$type|"] = box + if (idx.isNotEmpty() && !out.containsKey("|$idx")) out["|$idx"] = box + } + return out + } + + private fun drawShapeTree(canvas: Canvas, tree: OoxmlNode, ctx: SlideContext, transform: Transform) { + for (node in tree.children) { + runCatching { + when (node.name) { + "p:sp" -> drawShape(canvas, node, ctx, transform) + "p:pic" -> drawPicture(canvas, node, ctx, transform) + "p:graphicFrame" -> drawGraphicFrame(canvas, node, ctx, transform) + "p:grpSp" -> { + // A group re-maps its children's coordinate space: `chOff`/`chExt` is the + // space the children were authored in, `off`/`ext` is where the group + // actually sits. Without composing that, every grouped shape lands at + // its raw authoring offset, which is usually off-slide entirely. + val child = groupTransform(node)?.let { compose(transform, it) } ?: transform + drawShapeTree(canvas, node, ctx, child) + } + } + } + } + } + + // ── Shapes ────────────────────────────────────────────────────────────── + + private fun drawShape(canvas: Canvas, shape: OoxmlNode, ctx: SlideContext, transform: Transform) { + val box = resolveBox(shape, ctx)?.let { transform.apply(it) } ?: return + val spPr = shape.child("p:spPr") + val rotation = spPr?.child("a:xfrm")?.attr("rot")?.toFloatOrNull()?.div(60000f) ?: 0f + + canvas.save() + if (rotation != 0f) canvas.rotate(rotation, box.centerX(), box.centerY()) + + val fill = spPr?.child("a:solidFill")?.let { solidFillColor(it, ctx.theme) } + if (fill != null && spPr.child("a:noFill") == null) { + canvas.drawRect(box.toRectF(), Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill }) + } + // An outline, when the author asked for one — this is what makes boxed callouts and + // table-like arrangements of rectangles still read as boxes. + spPr?.child("a:ln")?.child("a:solidFill")?.let { solidFillColor(it, ctx.theme) }?.let { stroke -> + val width = (spPr.child("a:ln")?.attr("w")?.toFloatOrNull()?.div(EmuPerPoint) ?: 1f).coerceIn(0.5f, 8f) + canvas.drawRect(box.toRectF(), Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = stroke; style = Paint.Style.STROKE; strokeWidth = width + }) + } + + shape.child("p:txBody")?.let { drawTextBody(canvas, it, box, ctx, placeholderType(shape), fill) } + canvas.restore() + } + + private fun drawPicture(canvas: Canvas, pic: OoxmlNode, ctx: SlideContext, transform: Transform) { + val box = resolveBox(pic, ctx)?.let { transform.apply(it) } ?: return + val embed = pic.find("a:blip")?.attr("r:embed") ?: return + val path = rels(ctx.slidePath)[embed] ?: return + val bitmap = decodeMedia(path, box.width(), box.height()) ?: return + canvas.drawBitmap(bitmap, null, box.toRectF(), Paint(Paint.FILTER_BITMAP_FLAG)) + bitmap.recycle() + } + + /** Tables arrive wrapped in a graphic frame; charts and SmartArt also do, and are skipped. */ + private fun drawGraphicFrame(canvas: Canvas, frame: OoxmlNode, ctx: SlideContext, transform: Transform) { + val box = frame.find("p:xfrm")?.let { boxOf(it) }?.let { transform.apply(it) } ?: return + val table = frame.find("a:tbl") ?: return + val grid = table.find("a:tblGrid")?.childrenNamed("a:gridCol").orEmpty() + .map { (it.attr("w")?.toFloatOrNull() ?: 0f) / EmuPerPoint } + val rows = table.childrenNamed("a:tr") + val totalW = grid.sum().takeIf { it > 1f } ?: box.width() + val scale = box.width() / totalW + val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(90, 0, 0, 0); style = Paint.Style.STROKE; strokeWidth = 0.6f + } + + var y = box.top + for (row in rows) { + val rowH = ((row.attr("h")?.toFloatOrNull() ?: 0f) / EmuPerPoint).takeIf { it > 1f } ?: 22f + var x = box.left + row.childrenNamed("a:tc").forEachIndexed { i, cell -> + val w = (grid.getOrNull(i) ?: (totalW / grid.size.coerceAtLeast(1))) * scale + val cellBox = Rect(x, y, x + w, y + rowH) + canvas.drawRect(cellBox.toRectF(), linePaint) + cell.child("a:txBody")?.let { drawTextBody(canvas, it, cellBox.inset(3f), ctx, null, null) } + x += w + } + y += rowH + if (y > box.bottom + rowH) break + } + } + + private fun placeholderType(shape: OoxmlNode): String? = shape.find("p:ph")?.attr("type") + + /** A shape's own ``, else the box its placeholder inherits from layout/master. */ + private fun resolveBox(shape: OoxmlNode, ctx: SlideContext): Rect? { + shapeBox(shape)?.let { return it } + val ph = shape.find("p:ph") ?: return null + val type = ph.attr("type") ?: "body" + val idx = ph.attr("idx") ?: "" + return ctx.placeholders["$type|$idx"] + ?: ctx.placeholders["$type|"] + ?: ctx.placeholders["|$idx"] + ?: ctx.placeholders["body|"] + } + + private fun decodeMedia(path: String, targetW: Float, targetH: Float): Bitmap? { + val data = entries[path] ?: return null + return runCatching { + val probe = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(data, 0, data.size, probe) + // Decoding a 12-megapixel photo to fill a 300 pt box is how a deck of screenshots + // turns into an OutOfMemoryError. Sample down to roughly twice the drawn size. + val wanted = maxOf(targetW, targetH, 1f) * 2f + var sample = 1 + while (maxOf(probe.outWidth, probe.outHeight) / sample > wanted) sample *= 2 + BitmapFactory.decodeByteArray( + data, 0, data.size, + BitmapFactory.Options().apply { inSampleSize = sample } + ) + }.getOrNull() + } + } + + // ── Text ──────────────────────────────────────────────────────────────────── + + private class SlideContext( + val theme: Map, + val slidePath: String, + val slideNumber: Int, + val placeholders: Map + ) + + /** Default point size when neither the run nor the placeholder says. */ + private fun defaultSize(placeholder: String?): Float = when (placeholder) { + "title", "ctrTitle" -> 36f + "subTitle" -> 22f + "ftr", "sldNum", "dt" -> 11f + else -> 17f + } + + private fun drawTextBody( + canvas: Canvas, + txBody: OoxmlNode, + box: Rect, + ctx: SlideContext, + placeholder: String?, + behindColor: Int? + ) { + val paragraphs = txBody.childrenNamed("a:p") + if (paragraphs.isEmpty()) return + + // Text over a filled shape has to stay legible against that fill, not against the slide. + val defaultInk = behindColor?.let { if (isLight(it)) Color.BLACK else Color.WHITE } + ?: ctx.theme["tx1"] ?: Color.BLACK + + val inset = 6f + val width = (box.width() - inset * 2).toInt().coerceAtLeast(24) + val layouts = mutableListOf>() // layout + its left indent + var totalHeight = 0f + + for (paragraph in paragraphs) { + val level = paragraph.child("a:pPr")?.attr("lvl")?.toIntOrNull() ?: 0 + val indent = level * 16f + val (spanned, size) = buildParagraph(paragraph, ctx, defaultInk, placeholder) + if (spanned.isEmpty()) { totalHeight += size * 0.6f; continue } + + val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = size + color = defaultInk + typeface = Typeface.SANS_SERIF + } + val layout = StaticLayout.Builder + .obtain(spanned, 0, spanned.length, paint, (width - indent).toInt().coerceAtLeast(24)) + .setAlignment(alignmentOf(paragraph, placeholder)) + .setLineSpacing(1f, 1.12f) + .setIncludePad(false) + .build() + layouts.add(layout to indent) + totalHeight += layout.height + size * 0.25f + } + if (layouts.isEmpty()) return + + // Vertical anchor. PowerPoint centres title placeholders by default, which is why an + // uncentred render of a title slide looks subtly wrong even when every word is right. + val anchor = txBody.child("a:bodyPr")?.attr("anchor") + ?: if (placeholder == "title" || placeholder == "ctrTitle") "ctr" else "t" + var y = when (anchor) { + "ctr" -> box.top + (box.height() - totalHeight) / 2f + "b" -> box.bottom - totalHeight + else -> box.top + inset + }.coerceAtLeast(box.top) + + canvas.save() + canvas.clipRect(box.left, box.top, box.right, box.bottom) + for ((layout, indent) in layouts) { + canvas.save() + canvas.translate(box.left + inset + indent, y) + layout.draw(canvas) + canvas.restore() + y += layout.height + layout.paint.textSize * 0.25f + } + canvas.restore() + } + + /** One `` → styled text plus the size its first run asked for (used for spacing). */ + private fun buildParagraph( + paragraph: OoxmlNode, + ctx: SlideContext, + defaultInk: Int, + placeholder: String? + ): Pair { + val out = SpannableStringBuilder() + var firstSize = 0f + + val bullet = when { + paragraph.child("a:pPr")?.child("a:buNone") != null -> null + paragraph.child("a:pPr")?.child("a:buChar") != null -> + paragraph.child("a:pPr")?.child("a:buChar")?.attr("char") ?: "•" + paragraph.child("a:pPr")?.child("a:buAutoNum") != null -> "•" + else -> null + } + if (bullet != null) out.append(bullet).append(" ") + + for (node in paragraph.children) { + when (node.name) { + "a:br" -> out.append("\n") + // `` is a live field — slide number, date. It carries a cached ``, but + // the number in it is whatever it was when the file was last saved, so the slide + // number is regenerated and everything else uses the cached text. + "a:fld", "a:r" -> { + val rPr = node.child("a:rPr") + val raw = node.child("a:t")?.textContent().orEmpty() + val value = if (node.name == "a:fld" && node.attr("type")?.startsWith("slidenum") == true) { + ctx.slideNumber.toString() + } else raw + if (value.isEmpty()) continue + + val start = out.length + out.append(value) + val end = out.length + + val size = rPr?.attr("sz")?.toFloatOrNull()?.div(100f)?.coerceIn(4f, 200f) + ?: defaultSize(placeholder) + if (firstSize == 0f) firstSize = size + out.setSpan(AbsoluteSizeSpan(Math.round(size)), start, end, 0) + + val bold = rPr?.attr("b") == "1" + val italic = rPr?.attr("i") == "1" + if (bold && italic) out.setSpan(StyleSpan(Typeface.BOLD_ITALIC), start, end, 0) + else if (bold) out.setSpan(StyleSpan(Typeface.BOLD), start, end, 0) + else if (italic) out.setSpan(StyleSpan(Typeface.ITALIC), start, end, 0) + if (rPr?.attr("u") != null && rPr.attr("u") != "none") out.setSpan(UnderlineSpan(), start, end, 0) + + val ink = rPr?.child("a:solidFill")?.let { solidFillColor(it, ctx.theme) } + if (ink != null && ink != defaultInk) out.setSpan(ForegroundColorSpan(ink), start, end, 0) + + rPr?.child("a:latin")?.attr("typeface")?.let { face -> + val family = when { + face.contains("Courier", true) || face.contains("Mono", true) -> "monospace" + face.contains("Times", true) || face.contains("Georgia", true) || + face.contains("Serif", true) -> "serif" + else -> null + } + if (family != null) out.setSpan(TypefaceSpan(family), start, end, 0) + } + } + } + } + if (firstSize == 0f) firstSize = defaultSize(placeholder) + return out to firstSize + } + + private fun alignmentOf(paragraph: OoxmlNode, placeholder: String?): Layout.Alignment { + val centredByDefault = placeholder == "ctrTitle" || placeholder == "subTitle" + return when (paragraph.child("a:pPr")?.attr("algn")) { + "ctr" -> Layout.Alignment.ALIGN_CENTER + "r" -> Layout.Alignment.ALIGN_OPPOSITE + null -> if (centredByDefault) Layout.Alignment.ALIGN_CENTER else Layout.Alignment.ALIGN_NORMAL + else -> Layout.Alignment.ALIGN_NORMAL + } + } + + // ── Geometry ──────────────────────────────────────────────────────────────── + + private class Rect(val left: Float, val top: Float, val right: Float, val bottom: Float) { + fun width() = right - left + fun height() = bottom - top + fun centerX() = (left + right) / 2f + fun centerY() = (top + bottom) / 2f + fun toRectF() = RectF(left, top, right, bottom) + fun inset(by: Float) = Rect(left + by, top + by, right - by, bottom - by) + } + + /** Scale + translate. A group's child space maps into slide space by exactly this much. */ + private class Transform(val scaleX: Float, val scaleY: Float, val dx: Float, val dy: Float) { + fun apply(r: Rect) = Rect( + r.left * scaleX + dx, r.top * scaleY + dy, + r.right * scaleX + dx, r.bottom * scaleY + dy + ) + } + + private fun identityTransform() = Transform(1f, 1f, 0f, 0f) + + private fun compose(outer: Transform, inner: Transform) = Transform( + outer.scaleX * inner.scaleX, + outer.scaleY * inner.scaleY, + outer.scaleX * inner.dx + outer.dx, + outer.scaleY * inner.dy + outer.dy + ) + + private fun groupTransform(group: OoxmlNode): Transform? { + val xfrm = group.find("a:xfrm") ?: return null + val off = xfrm.child("a:off") ?: return null + val ext = xfrm.child("a:ext") ?: return null + val chOff = xfrm.child("a:chOff") ?: return null + val chExt = xfrm.child("a:chExt") ?: return null + + val x = (off.attr("x")?.toFloatOrNull() ?: 0f) / EmuPerPoint + val y = (off.attr("y")?.toFloatOrNull() ?: 0f) / EmuPerPoint + val w = (ext.attr("cx")?.toFloatOrNull() ?: 0f) / EmuPerPoint + val h = (ext.attr("cy")?.toFloatOrNull() ?: 0f) / EmuPerPoint + val cx = (chOff.attr("x")?.toFloatOrNull() ?: 0f) / EmuPerPoint + val cy = (chOff.attr("y")?.toFloatOrNull() ?: 0f) / EmuPerPoint + val cw = (chExt.attr("cx")?.toFloatOrNull() ?: 0f) / EmuPerPoint + val ch = (chExt.attr("cy")?.toFloatOrNull() ?: 0f) / EmuPerPoint + + val sx = if (cw > 0.01f) w / cw else 1f + val sy = if (ch > 0.01f) h / ch else 1f + return Transform(sx, sy, x - cx * sx, y - cy * sy) + } + + private fun shapeBox(shape: OoxmlNode): Rect? = shape.child("p:spPr")?.child("a:xfrm")?.let { boxOf(it) } + + private fun boxOf(xfrm: OoxmlNode): Rect? { + val off = xfrm.child("a:off") ?: return null + val ext = xfrm.child("a:ext") ?: return null + val x = (off.attr("x")?.toFloatOrNull() ?: return null) / EmuPerPoint + val y = (off.attr("y")?.toFloatOrNull() ?: return null) / EmuPerPoint + val w = (ext.attr("cx")?.toFloatOrNull() ?: return null) / EmuPerPoint + val h = (ext.attr("cy")?.toFloatOrNull() ?: return null) / EmuPerPoint + if (w <= 0f || h <= 0f) return null + return Rect(x, y, x + w, y + h) + } + + // ── Colour ────────────────────────────────────────────────────────────────── + + private fun solidFillColor(fill: OoxmlNode, theme: Map): Int? { + val node = fill.child("a:solidFill") ?: fill + node.child("a:srgbClr")?.attr("val")?.let { hex -> parseSrgb(hex)?.let { return withMods(it, node.child("a:srgbClr")) } } + node.child("a:schemeClr")?.let { scheme -> + val name = scheme.attr("val") ?: return@let + theme[name]?.let { return withMods(it, scheme) } + // A deck whose theme part is missing still has to render; these are the defaults + // PowerPoint itself falls back to. + return when (name) { + "tx1", "dk1" -> Color.BLACK + "bg1", "lt1" -> Color.WHITE + "tx2", "dk2" -> Color.rgb(0x44, 0x44, 0x44) + "bg2", "lt2" -> Color.rgb(0xEE, 0xEE, 0xEE) + else -> null + } + } + return null + } + + /** `` / `` / `` — the tints that make a theme's palette. */ + private fun withMods(base: Int, node: OoxmlNode?): Int { + if (node == null) return base + var r = Color.red(base) / 255f + var g = Color.green(base) / 255f + var b = Color.blue(base) / 255f + + node.child("a:lumMod")?.attr("val")?.toFloatOrNull()?.let { v -> + val f = v / 100000f + r *= f; g *= f; b *= f + } + node.child("a:lumOff")?.attr("val")?.toFloatOrNull()?.let { v -> + val f = v / 100000f + r += f; g += f; b += f + } + val alpha = node.child("a:alpha")?.attr("val")?.toFloatOrNull()?.div(100000f) ?: 1f + return Color.argb( + Math.round(alpha.coerceIn(0f, 1f) * 255), + Math.round(r.coerceIn(0f, 1f) * 255), + Math.round(g.coerceIn(0f, 1f) * 255), + Math.round(b.coerceIn(0f, 1f) * 255) + ) + } + + private fun parseSrgb(hex: String?): Int? { + if (hex.isNullOrBlank()) return null + return runCatching { Color.parseColor(if (hex.startsWith("#")) hex else "#$hex") }.getOrNull() + } + + private fun isLight(color: Int): Boolean = + (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) > 150 +} diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/SpreadsheetParser.kt b/app/src/main/java/com/chethan616/clearpdf/utils/SpreadsheetParser.kt index 9f24e9c..48d6f25 100644 --- a/app/src/main/java/com/chethan616/clearpdf/utils/SpreadsheetParser.kt +++ b/app/src/main/java/com/chethan616/clearpdf/utils/SpreadsheetParser.kt @@ -13,15 +13,41 @@ import java.util.zip.ZipInputStream * interactive spreadsheet viewer (as opposed to [UniversalDocumentConverter], which flattens to a * static PDF). Cells are placed at their true column index (from the r="C5" ref) so omitted/empty * cells don't shift data, and inline strings + shared strings are both handled. Self-contained. + * + * Two things it deliberately reads beyond the raw cell values, because leaving them out made the + * viewer disagree with Excel on the same file: + * - `xl/styles.xml`, so a date cell renders as a date instead of its serial number (see + * [ExcelCellFormat]); + * - the `hidden` flags on ``/``, so a column the author hid stays hidden here too. + * Sheets routinely carry helper columns that are hidden on purpose; showing them made the app + * look like it was inventing data that "isn't in the file". */ object SpreadsheetParser { /** Widest row this parser will materialise. See the note at the `"c"` end-tag. */ private const val MaxColumns = 1024 - data class Sheet(val name: String, val rows: List>) { + /** + * @param columnLabels the spreadsheet letter for each rendered column. Not simply `A, B, C…`: + * hidden columns are dropped from [rows], so a sheet that hides H renders `… G, I …` exactly + * as Excel's own header does. + * @param rowNumbers the real 1-based sheet row number for each rendered row, for the same + * reason — and so the viewer's row gutter agrees with the reference the user reads on desktop. + */ + data class Sheet( + val name: String, + val rows: List>, + val columnLabels: List = emptyList(), + val rowNumbers: List = emptyList() + ) { /** Widest row → number of columns to render. */ val columnCount: Int get() = rows.maxOfOrNull { it.size } ?: 0 + + /** Header letter for a rendered column, falling back to positional letters. */ + fun labelAt(index: Int): String = columnLabels.getOrNull(index) ?: colLetter(index) + + /** Sheet row number for a rendered row, falling back to positional numbering. */ + fun rowNumberAt(index: Int): Int = rowNumbers.getOrNull(index) ?: (index + 1) } /** @@ -46,6 +72,14 @@ object SpreadsheetParser { } }.getOrDefault(emptyList()) + /** 0→A, 25→Z, 26→AA … spreadsheet column labels. */ + fun colLetter(index: Int): String { + var i = index + val sb = StringBuilder() + while (i >= 0) { sb.insert(0, 'A' + (i % 26)); i = i / 26 - 1 } + return sb.toString() + } + // ── XLSX (Office Open XML) ─────────────────────────────────────────────────── fun parseXlsx(bytes: ByteArray): List = bytes.inputStream().use { parseXlsx(it) } @@ -55,18 +89,19 @@ object SpreadsheetParser { ZipInputStream(source).use { zip -> while (true) { val entry = zip.nextEntry ?: break - // Only the four things below are ever read again. Keeping the rest was the single - // biggest allocation in this parser: `xl/media/*` (embedded images, already the - // bulk of many workbooks) and `xl/calcChain.xml` (one node per formula cell, often - // larger than the sheets themselves) were being decompressed into the heap in full - // and never touched. Skipping them is what keeps a mid-size workbook off the OOM - // line, since every entry kept here stays reachable until parsing finishes. + // Only the parts below are ever read again. Keeping the rest was the single biggest + // allocation in this parser: `xl/media/*` (embedded images, already the bulk of many + // workbooks) and `xl/calcChain.xml` (one node per formula cell, often larger than + // the sheets themselves) were being decompressed into the heap in full and never + // touched. Skipping them is what keeps a mid-size workbook off the OOM line, since + // every entry kept here stays reachable until parsing finishes. if (isNeeded(entry.name)) entries[entry.name] = zip.readBytes() } } val sharedStrings = entries["xl/sharedStrings.xml"]?.let { parseSharedStrings(it.inputStream()) } ?: emptyList() - val workbookSheets = parseWorkbookSheets(entries["xl/workbook.xml"]) // (name, rId) in tab order - val rels = parseRels(entries["xl/_rels/workbook.xml.rels"]) // rId → "worksheets/sheetN.xml" + val formatCodes = parseStyles(entries["xl/styles.xml"]) // cellXfs index → format code + val workbookSheets = parseWorkbookSheets(entries["xl/workbook.xml"]) // (name, rId) in tab order + val rels = parseRels(entries["xl/_rels/workbook.xml.rels"]) // rId → "worksheets/sheetN.xml" val ordered: List> = if (workbookSheets.isNotEmpty()) { workbookSheets.mapNotNull { (name, rId) -> @@ -80,8 +115,78 @@ object SpreadsheetParser { .map { (Regex("sheet(\\d+)").find(it)?.groupValues?.getOrNull(1)?.let { n -> "Sheet $n" } ?: "Sheet") to entries[it]!! } } - return ordered.map { (name, xml) -> Sheet(name, parseWorksheetRows(xml.inputStream(), sharedStrings)) } - .filter { it.rows.isNotEmpty() } + return ordered.map { (name, xml) -> + buildSheet(name, parseWorksheet(xml.inputStream(), sharedStrings, formatCodes)) + }.filter { it.rows.isNotEmpty() } + } + + /** Raw worksheet contents, before hidden columns are folded out. */ + private class RawSheet { + val rows = mutableListOf>() + val rowNumbers = mutableListOf() + val hiddenCols = HashSet() + } + + /** + * Drops the columns the author hid and derives the header letters / row numbers that survive. + * The labels come from the *original* indices, which is the whole point: after removing H, the + * remaining headers must still read G then I, or the viewer silently renames the user's columns. + */ + private fun buildSheet(name: String, raw: RawSheet): Sheet { + val width = raw.rows.maxOfOrNull { it.size } ?: 0 + val visible = (0 until width).filter { it !in raw.hiddenCols } + // A sheet with everything hidden is almost certainly a file we misread; showing it beats + // showing an empty grid. + if (visible.isEmpty() || visible.size == width) { + return Sheet(name, raw.rows, (0 until width).map { colLetter(it) }, raw.rowNumbers) + } + val rows = raw.rows.map { row -> visible.map { row.getOrElse(it) { "" } } } + return Sheet(name, rows, visible.map { colLetter(it) }, raw.rowNumbers) + } + + /** + * `xl/styles.xml` → format code per `cellXfs` index, which is what a cell's `s="7"` points at. + * Custom codes live in ``; everything else is a built-in id resolved by + * [ExcelCellFormat]. Returns empty when there is no styles part, in which case values render + * exactly as they are stored. + */ + private fun parseStyles(bytes: ByteArray?): List { + if (bytes == null) return emptyList() + val custom = HashMap() + val numFmtIds = mutableListOf() + return runCatching { + val parser = newParser(bytes.inputStream()) + var inCellXfs = false + // `` (conditional-formatting overrides) carries its own `` children with + // ids that can collide with the workbook's real ones. Only the top-level `` + // block defines what a cell's `numFmtId` means. + var inDxfs = false + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> when (parser.name) { + "dxfs" -> inDxfs = true + "numFmt" -> if (!inDxfs) { + val id = parser.getAttributeValue(null, "numFmtId")?.toIntOrNull() + val code = parser.getAttributeValue(null, "formatCode") + if (id != null && code != null) custom[id] = code + } + // `cellStyleXfs` has the same `` children and comes first; only the + // `cellXfs` block is indexed by a cell's `s` attribute. + "cellXfs" -> inCellXfs = true + "xf" -> if (inCellXfs) { + numFmtIds.add(parser.getAttributeValue(null, "numFmtId")?.toIntOrNull() ?: 0) + } + } + XmlPullParser.END_TAG -> when (parser.name) { + "cellXfs" -> inCellXfs = false + "dxfs" -> inDxfs = false + } + } + event = parser.next() + } + ExcelCellFormat.formatCodesFor(numFmtIds, custom) + }.getOrDefault(emptyList()) } private fun parseWorkbookSheets(bytes: ByteArray?): List> { @@ -144,13 +249,20 @@ object SpreadsheetParser { return if (sawLetter) idx - 1 else -1 } - private fun parseWorksheetRows(stream: InputStream, sharedStrings: List): List> { - val rows = mutableListOf>() + private fun parseWorksheet( + stream: InputStream, + sharedStrings: List, + formatCodes: List + ): RawSheet { + val out = RawSheet() val parser = newParser(stream) var rowCells = sortedMapOf() var cellType = "" + var cellStyle = -1 var cellCol = 0 var nextAutoCol = 0 + var rowNumber = 0 + var rowHidden = false var inVal = false val cellBuf = StringBuilder() @@ -158,22 +270,47 @@ object SpreadsheetParser { while (event != XmlPullParser.END_DOCUMENT) { when (event) { XmlPullParser.START_TAG -> when (parser.name) { - "row" -> { rowCells = sortedMapOf(); nextAutoCol = 0 } + "col" -> { + // `hidden="1"` on a `` spans min..max, so one entry can hide a range. + if (parser.getAttributeValue(null, "hidden") == "1") { + val min = parser.getAttributeValue(null, "min")?.toIntOrNull() ?: 0 + val max = parser.getAttributeValue(null, "max")?.toIntOrNull() ?: min + for (c in min..max.coerceAtMost(MaxColumns)) if (c >= 1) out.hiddenCols.add(c - 1) + } + } + "row" -> { + rowCells = sortedMapOf() + nextAutoCol = 0 + rowNumber = parser.getAttributeValue(null, "r")?.toIntOrNull() ?: (out.rows.size + 1) + rowHidden = parser.getAttributeValue(null, "hidden") == "1" + } "c" -> { cellType = parser.getAttributeValue(null, "t") ?: "" + cellStyle = parser.getAttributeValue(null, "s")?.toIntOrNull() ?: -1 cellCol = colIndexFromRef(parser.getAttributeValue(null, "r")).let { if (it >= 0) it else nextAutoCol } inVal = false; cellBuf.clear() } "v", "t" -> inVal = true } XmlPullParser.END_TAG -> when (parser.name) { - "row" -> if (rowCells.isNotEmpty()) { - val maxC = rowCells.lastKey() - rows.add((0..maxC).map { rowCells[it] ?: "" }) - } else rows.add(emptyList()) + "row" -> if (!rowHidden) { + if (rowCells.isNotEmpty()) { + val maxC = rowCells.lastKey() + out.rows.add((0..maxC).map { rowCells[it] ?: "" }) + } else out.rows.add(emptyList()) + out.rowNumbers.add(rowNumber) + } "c" -> { val raw = cellBuf.toString() - val value = if (cellType == "s") sharedStrings.getOrElse(raw.trim().toIntOrNull() ?: -1) { raw } else raw + val value = when (cellType) { + "s" -> sharedStrings.getOrElse(raw.trim().toIntOrNull() ?: -1) { raw } + "b" -> if (raw.trim() == "1") "TRUE" else "FALSE" + "str", "inlineStr", "e" -> raw + // Numeric (the default, `t` absent) — this is where a date lives, and + // where reading the style is the difference between "02-09-2026" and + // the bare serial "46267". + else -> ExcelCellFormat.apply(raw, formatCodes.getOrNull(cellStyle)) + } // The column cap is what stops one malformed `r` ref from sizing the whole // sheet: the row is materialised as a dense `0..maxKey` list, so a single // cell claiming to be at XFD would allocate 16384 strings for every row in @@ -188,11 +325,14 @@ object SpreadsheetParser { XmlPullParser.TEXT -> if (inVal) cellBuf.append(parser.text) } event = parser.next() - if (rows.size > 20000) break + if (out.rows.size > 20000) break } // Trim trailing fully-empty rows. - while (rows.isNotEmpty() && rows.last().all { it.isBlank() }) rows.removeAt(rows.lastIndex) - return rows + while (out.rows.isNotEmpty() && out.rows.last().all { it.isBlank() }) { + out.rows.removeAt(out.rows.lastIndex) + if (out.rowNumbers.isNotEmpty()) out.rowNumbers.removeAt(out.rowNumbers.lastIndex) + } + return out } // ── Legacy XLS (POI) ───────────────────────────────────────────────────────── @@ -201,11 +341,17 @@ object SpreadsheetParser { HSSFWorkbook(bytes.inputStream()).use { wb -> return (0 until wb.numberOfSheets).map { si -> val sheet = wb.getSheetAt(si) - val rows = sheet.map { row -> - val lastCol = row.lastCellNum.toInt().coerceAtLeast(0) - (0 until lastCol).map { c -> row.getCell(c)?.toString()?.trim() ?: "" } - } - Sheet(wb.getSheetName(si) ?: "Sheet ${si + 1}", rows) + val width = sheet.maxOfOrNull { it.lastCellNum.toInt().coerceAtLeast(0) } ?: 0 + val visible = (0 until width).filter { !sheet.isColumnHidden(it) } + .ifEmpty { (0 until width).toList() } + val kept = sheet.filter { !it.zeroHeight } + val rows = kept.map { row -> visible.map { c -> row.getCell(c)?.toString()?.trim() ?: "" } } + Sheet( + name = wb.getSheetName(si) ?: "Sheet ${si + 1}", + rows = rows, + columnLabels = visible.map { colLetter(it) }, + rowNumbers = kept.map { it.rowNum + 1 } + ) }.filter { it.rows.isNotEmpty() } } } @@ -222,6 +368,7 @@ object SpreadsheetParser { return n.endsWith("workbook.xml") || n.endsWith("workbook.xml.rels") || n.endsWith("sharedStrings.xml") || + n.endsWith("styles.xml") || (n.contains("worksheets/") && n.endsWith(".xml")) } diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt b/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt index e8c0de6..cfc1f50 100644 --- a/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt +++ b/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt @@ -48,9 +48,9 @@ object UniversalDocumentConverter { return when { mimeType.startsWith("image/") || name.endsWithAny(".png", ".jpg", ".jpeg", ".webp", ".bmp", ".heic") -> convertImageToPdf(context, sourceUri) - name.endsWith(".docx") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.DOCX) + name.endsWith(".docx") -> convertDocxToPdf(context, sourceUri) name.endsWith(".xlsx") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.XLSX) - name.endsWith(".pptx") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.PPTX) + name.endsWith(".pptx") -> convertPptxToPdf(context, sourceUri) name.endsWith(".odt") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.ODT) name.endsWith(".doc") -> convertLegacyWordToPdf(context, sourceUri) name.endsWith(".xls") -> convertLegacyXlsToPdf(context, sourceUri) @@ -109,6 +109,32 @@ object UniversalDocumentConverter { // ── DOCX ─────────────────────────────────────────────────────────────────── + /** + * Word documents get real layout from [DocxWebRenderer] when the device can provide it, and + * fall back to [parseDocx]'s reflow when it can't. + * + * The reflow re-lays Word content onto a fixed A4 page with this file's own margins and fonts; + * it reads well but is not the author's document. The renderer instead lays the .docx out with + * `docx-preview` in an offscreen WebView and prints that, which keeps the document's real page + * size, margins, tables, headers and footers. It costs ~48 KB of assets rather than the tens of + * megabytes a native Office engine would. + * + * The fallback is not decoration: the print path leans on driving a `PrintDocumentAdapter` + * directly, and if that is unavailable on a device, .docx must keep opening exactly as it did + * before rather than failing. + */ + private fun convertDocxToPdf(context: Context, sourceUri: Uri): Uri { + val bytes = context.contentResolver.openInputStream(sourceUri)?.use { it.readBytes() } + ?: throw IllegalStateException("Cannot open file") + + val dir = File(context.cacheDir, "converted_pdfs").also { it.mkdirs() } + val rendered = File(dir, "Docx_${System.currentTimeMillis()}.pdf") + val ok = runCatching { DocxWebRenderer.render(context, bytes, rendered) }.getOrDefault(false) + if (ok) return Uri.fromFile(rendered) + + return writePdf(context, renderBlocks(parseDocx(bytes), bodyPaint()), "Docx") + } + private fun parseDocx(bytes: ByteArray): List { // Read the whole package so we can resolve inline images (drawing → r:embed → rels → media). val entries = HashMap() @@ -289,69 +315,28 @@ object UniversalDocumentConverter { // ── XLSX ─────────────────────────────────────────────────────────────────── + /** + * Delegates to [SpreadsheetParser], which is the same reader the interactive spreadsheet viewer + * uses. This file used to carry a second, simpler copy of the .xlsx parser, and the two drifted: + * the copy here knew nothing about `xl/styles.xml` or hidden columns, so exporting a workbook to + * PDF printed date cells as their raw serial numbers and printed helper columns the author had + * hidden. One parser means the exported PDF and the on-screen grid can no longer disagree. + */ private fun parseXlsx(bytes: ByteArray): List { - // Read the whole package once so we can resolve workbook order + real sheet names via the - // rels — instead of guessing from the zip's arbitrary entry order. - val entries = HashMap() - ZipInputStream(bytes.inputStream()).use { zip -> - while (true) { - val entry = zip.nextEntry ?: break - entries[entry.name] = zip.readBytes() - } - } - val sharedStrings = entries["xl/sharedStrings.xml"]?.let { parseSharedStrings(it.inputStream()) } ?: emptyList() - - // Workbook defines sheets in TAB order with their names + an r:id → the rels map that r:id - // to the actual sheetN.xml file. This gives correct order AND the human sheet name. - val workbookSheets = parseWorkbookSheets(entries["xl/workbook.xml"]) // (name, rId) in tab order - val rels = parseRels(entries["xl/_rels/workbook.xml.rels"]) // rId → "worksheets/sheetN.xml" - val ordered: List> = if (workbookSheets.isNotEmpty()) { - workbookSheets.mapNotNull { (name, rId) -> - val target = rels[rId] ?: return@mapNotNull null - val path = if (target.startsWith("/")) target.drop(1) else "xl/${target.removePrefix("/")}" - entries[path]?.let { name to it } - } - } else { - // Fallback: every worksheet by numeric index, generic "Sheet N" names. - entries.keys.filter { it.startsWith("xl/worksheets/sheet") && it.endsWith(".xml") } - .sortedBy { Regex("sheet(\\d+)\\.xml").find(it)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: Int.MAX_VALUE } - .map { (Regex("sheet(\\d+)").find(it)?.groupValues?.getOrNull(1)?.let { n -> "Sheet $n" } ?: "Sheet") to entries[it]!! } - } - + val sheets = runCatching { SpreadsheetParser.parseXlsx(bytes) }.getOrDefault(emptyList()) val result = mutableListOf() - for ((name, xml) in ordered) { - val rows = parseWorksheetRows(xml.inputStream(), sharedStrings) - if (rows.isEmpty()) continue + for (sheet in sheets) { + if (sheet.rows.isEmpty()) continue // A labelled header for each sheet so a multi-sheet workbook reads as clearly separated // sections instead of one anonymous run of tables. - val header = SpannableStringBuilder(name) + val header = SpannableStringBuilder(sheet.name) header.setSpan(StyleSpan(Typeface.BOLD), 0, header.length, 0) result.add(DocBlock.Para(header, headingLevel = 1, spaceAfter = 6f)) - result.add(DocBlock.Table(rows)) + result.add(DocBlock.Table(sheet.rows)) } return result.ifEmpty { listOf(DocBlock.Para(SpannableStringBuilder("(empty spreadsheet)"))) } } - /** Sheets in workbook (tab) order as (name, relationshipId). */ - private fun parseWorkbookSheets(bytes: ByteArray?): List> { - if (bytes == null) return emptyList() - val out = mutableListOf>() - val parser = Xml.newPullParser().apply { - setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - setInput(bytes.inputStream(), "UTF-8") - } - var event = parser.eventType - while (event != XmlPullParser.END_DOCUMENT) { - if (event == XmlPullParser.START_TAG && parser.name == "sheet") { - val name = parser.getAttributeValue(null, "name") ?: "Sheet" - val rId = parser.getAttributeValue(null, "r:id") ?: parser.getAttributeValue(null, "id") ?: "" - out.add(name to rId) - } - event = parser.next() - } - return out - } - /** "#RRGGBB" / "RRGGBB" / "auto" → ARGB int, or 0 (sentinel = none) when absent/invalid. */ private fun parseHexColor(hex: String?): Int { if (hex.isNullOrBlank() || hex.equals("auto", ignoreCase = true)) return 0 @@ -378,93 +363,24 @@ object UniversalDocumentConverter { return out } - private fun parseSharedStrings(stream: InputStream): List { - val strings = mutableListOf() - val parser = Xml.newPullParser().apply { - setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - setInput(stream, "UTF-8") - } - var inT = false - val buf = StringBuilder() - var event = parser.eventType - while (event != XmlPullParser.END_DOCUMENT) { - when (event) { - XmlPullParser.START_TAG -> if (parser.name == "si") buf.clear() - else if (parser.name == "t") inT = true - XmlPullParser.END_TAG -> if (parser.name == "si") { strings.add(buf.toString()); inT = false } - else if (parser.name == "t") inT = false - XmlPullParser.TEXT -> if (inT) buf.append(parser.text) - } - event = parser.next() - } - return strings - } - - /** Converts a spreadsheet column reference ("A", "B", … "AA") from a cell ref like "AB12" to a - * 0-based column index. Returns -1 if there are no leading letters. */ - private fun colIndexFromRef(ref: String?): Int { - if (ref.isNullOrEmpty()) return -1 - var idx = 0 - var sawLetter = false - for (ch in ref) { - val up = ch.uppercaseChar() - if (up in 'A'..'Z') { idx = idx * 26 + (up - 'A' + 1); sawLetter = true } else break - } - return if (sawLetter) idx - 1 else -1 - } - - private fun parseWorksheetRows(stream: InputStream, sharedStrings: List): List> { - val rows = mutableListOf>() - val parser = Xml.newPullParser().apply { - setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - setInput(stream, "UTF-8") - } - // Place every cell at its REAL column index (from the r="C5" ref) so omitted/empty cells — - // which OOXML simply leaves out — don't shift the rest of the row left. That left-shift was - // the main reason spreadsheet values looked "missing" or landed under the wrong header here. - var rowCells = sortedMapOf() - var cellType = "" - var cellCol = 0 - var nextAutoCol = 0 - var inVal = false // inside (value) or inline (inline string text) - val cellBuf = StringBuilder() + // ── PPTX ─────────────────────────────────────────────────────────────────── - var event = parser.eventType - while (event != XmlPullParser.END_DOCUMENT) { - when (event) { - XmlPullParser.START_TAG -> when (parser.name) { - "row" -> { rowCells = sortedMapOf(); nextAutoCol = 0 } - "c" -> { - cellType = parser.getAttributeValue(null, "t") ?: "" - cellCol = colIndexFromRef(parser.getAttributeValue(null, "r")).let { if (it >= 0) it else nextAutoCol } - inVal = false; cellBuf.clear() - } - "v", "t" -> inVal = true // here = an inline-string cell (…) - } - XmlPullParser.END_TAG -> when (parser.name) { - "row" -> if (rowCells.isNotEmpty()) { - val maxC = rowCells.lastKey() - rows.add((0..maxC).map { rowCells[it] ?: "" }) - } - "c" -> { - val raw = cellBuf.toString() - val value = if (cellType == "s") sharedStrings.getOrElse(raw.trim().toIntOrNull() ?: -1) { raw } else raw - if (value.isNotEmpty()) rowCells[cellCol] = value - nextAutoCol = cellCol + 1 - inVal = false - } - "v", "t" -> inVal = false - } - XmlPullParser.TEXT -> if (inVal) cellBuf.append(parser.text) - } - event = parser.next() - if (rows.size > 5000) break - } - return rows + /** + * A presentation gets one landscape page per slide, laid out by [PptxRenderer]. + * + * [parsePptx] below is kept as the fallback for a package the renderer can't make sense of — a + * missing `presentation.xml`, a producer that writes a shape tree we don't recognise. Its output + * is a plain text outline, which is a poor presentation but is still readable, and is strictly + * better than handing back a blank document. + */ + private fun convertPptxToPdf(context: Context, sourceUri: Uri): Uri { + val bytes = context.contentResolver.openInputStream(sourceUri)?.use { it.readBytes() } + ?: throw IllegalStateException("Cannot open file") + val rendered = runCatching { PptxRenderer.render(bytes) }.getOrNull() + if (rendered != null) return writePdf(context, rendered, "Pptx") + return writePdf(context, renderBlocks(parsePptx(bytes), bodyPaint()), "Pptx") } - // ── PPTX ─────────────────────────────────────────────────────────────────── - private fun parsePptx(bytes: ByteArray): List { val result = mutableListOf() // Collect slides keyed by their numeric index so they render in order — ZipInputStream diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..d020f49 --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,526 @@ + + + ClearPDF + + + Abrir PDF + Abrir un archivo PDF + Combinar PDFs + Combinar varios PDFs + Comprimir + Comprimir un archivo PDF + Crear PDF + Crear un PDF nuevo + + + Bienvenido a ClearPDF + Tu compañero de documentos totalmente sin conexión + Elige tu idioma + Personaliza tu experiencia + Idioma + Hazlo tuyo + Elige un tema y un fondo. Cambia ambos cuando quieras en Ajustes. + Abre cualquier cosa + PDFs, documentos, hojas de cálculo, presentaciones, imágenes y más + Herramientas potentes + Anota, firma, busca y organiza documentos + Continuar + Empezar + %1$d de %2$d + Omitir + Ver la introducción de nuevo + Repetir el recorrido de bienvenida + + + ClearPDF + ESPACIO DE TRABAJO PDF + Trabajo en PDF, con sencilla elegancia. + Abre, escanea y organiza tus archivos — en privado, en tu dispositivo. + Abrir PDF + Escanear + Recientes + Ver todo + Ver menos + Sin archivos recientes + Abre un PDF para empezar + EN EL DISPOSITIVO + + + Buscar + Borrar búsqueda + + + Herramientas + Todo lo que necesitas para trabajar con PDFs. + Buscar herramientas + No hay herramientas que coincidan + Organizar + Convertir + Editar + Optimizar y proteger + Abrir PDF + Ver y leer + Combinar PDFs + Unir archivos + Dividir PDF + Extraer páginas + Comprimir + Reducir el tamaño del archivo + Organizar + Reordenar y rotar + Imágenes a PDF + Fotos a PDF + PDF a imágenes + Exportar páginas como JPG/PNG + Marca de agua + Estampar texto sobre las páginas + Marca de agua + Texto + Imagen + Elegir imagen de marca de agua + p. ej. CONFIDENCIAL + Opacidad + Diagonal (45°) + Aplicar marca de agua + Aplicando… + Personalización + Fondo + Mostrar la imagen de fondo detrás de la app + Política de privacidad + Versión + Extraer páginas + Pasar páginas a un PDF nuevo + Páginas a extraer + p. ej. 1-3, 5, 8-10 + de %1$d + Extraer páginas + Extrayendo… + Números de página + Numerar todas las páginas + Posición + Centro + Derecha + Mostrar total + Mostrar como "3 / 12" en lugar de "3" + Añadir números de página + Añadiendo… + Aplanar PDF + Hacer los formularios permanentes + Convierte los campos de formulario interactivos en contenido estático que ya no se puede editar. + Los valores del formulario se vuelven contenido permanente de la página. + Aplanar PDF + Aplanando… + Herramientas de imagen + Comprimir, redimensionar, convertir + Elegir imagen + Opciones + Formato + Calidad + Redimensionar + Los metadatos (EXIF/GPS) se eliminan al exportar. + Procesar imagen + Procesando… + Guardar + Compartir + Web a PDF + URL o HTML a PDF + URL web + HTML + Escribe una dirección web para capturar la página como PDF. + https://ejemplo.com + Pega HTML o carga un archivo .html — renderizado en el dispositivo. + Cargar archivo .html + <h1>Hola</h1><p>Tu HTML aquí…</p> + Convertir a PDF + Renderizando… + Rellenar formulario + Completar campos de un formulario PDF + Campos del formulario + Aplanar al guardar + Hacer permanentes los valores introducidos + Guardar PDF rellenado + Guardando… + Renderiza cada página como una imagen de alta calidad que puedes guardar o compartir. + Formato de imagen + Convertir a imágenes + Convirtiendo… %1$d%% + Páginas renderizadas + Guardar en la galería + Extraer texto + Copiar texto + Crear PDF + En blanco, imágenes, texto + + + Ajustes + Apariencia + Automático + Claro + Oscuro + Seguir los ajustes del sistema + Usar siempre el tema claro + Usar siempre el tema oscuro + Ubicación de guardado + Descargas + Carpeta personalizada + Gestión de archivos + Autocompresión + Comprimir automáticamente los PDF al importarlos + Conservar el original + Conservar el archivo original tras editar + Calidad de compresión + Menor tamaño + Mayor calidad + Idioma + Elige el idioma de la aplicación + Acerca de + Versión 1.1.0 + Hecho por Chethan616 con ❤ + Marcar con estrella en GitHub + ClearPDF es de código abierto + Licencias de código abierto + + + Visor de PDF + Abrir un PDF + Elegir un PDF + Selecciona un archivo PDF de tu dispositivo para verlo + Página %1$d / %2$d + Herramientas de dibujo + Seleccionar texto + Añadir imagen + Borrador + Guardar cambios + Firmar + Buscar + Buscar en el documento… + %1$d de %2$d resultados + Sin resultados + Anterior + Siguiente + Abrir otro PDF + Compartir documento + Herramientas de edición + Herramientas de edición próximamente + Abrir PDF + Lápiz + Resaltar + Rectángulo + Óvalo + Línea + Flecha + Deshacer + Borrar + Seleccionar todo + Subrayar + Tachar + Hecho + Color + Editar forma + Nueva firma + Reemplazar + Guardando PDF editado… + Restablecer zoom %1$d%% + Tamaño + Ir a la página + Página + Ir + OCR (%1$d) + + + Compartir + Formato + Cifrar con contraseña + Contraseña + Compartir + PDF + Normal + Cifrado + + + Dibuja tu firma + Guardada + Borrar + Usar firma + Fina + Media + Gruesa + Muy gruesa + Firma aquí + Firmas guardadas + Ponle un nombre a esta firma + Guardar nombre + Nombra tu firma + ¿Eliminar firma? + Se eliminará “%1$s” de forma permanente. + + + Anota y firma + Dibuja, resalta y firma tus PDF + Busca y selecciona + Busca texto y selecciona párrafos enteros + Herramientas potentes + Combina, divide, comprime y convierte + ¡Todo listo! 🎉 + ClearPDF está listo para usarse.\nTodos tus documentos, siempre privados. + 100% sin conexión · Sin recopilación de datos · Código abierto + + + Atrás + Hecho + Cancelar + Aceptar + Copiar + Editar + Compartir + Eliminar + Abrir + Guardar + Descartar + Nota adhesiva + Insertar texto + Escribe aquí… + Guardar + Ahora no + No volver a preguntar + Directorio personalizado + Directorio predeterminado + Descargas / ClearPDF + Cambiar carpeta + Restablecer + Galería + Licenciado bajo la Apache License, versión 2.0.\nPuedes obtener una copia en apache.org/licenses/LICENSE-2.0 + por %1$s + Inglés + Portugués + Español + Abrir + Compartir + Detalles + Quitar + Compartir PDF + PDF + Mantén pulsado para acciones rápidas + Buscar en recientes + No hay archivos que coincidan + Hoja anterior + Hoja siguiente + %1$d de %2$d + Valor de la celda + Celda vacía + Hoja %1$d de %2$d + Hoja %1$d / %2$d + HOJAS + Exportar PDF + Acercar + Alejar + Editar imagen + Girar a la izquierda + Girar a la derecha + Restablecer + Brillo + Contraste + Guardar en la galería + Exportar PDF + Original + Monocromo + Sepia + Vívido + Frío + Cálido + Información del archivo + Páginas + Añadido + Ubicación + Desconocido + Justo ahora + Hace %1$d min + Hace %1$d h + Ayer + %1$d KB + %1$.1f MB + %1$d B + %1$d páginas + Tamaño + %1$d hojas + Hojas + Fijar + Dejar de fijar + Filtrar por tipo + Todos + PDF + Word + Excel + Diapositivas + Imágenes + Desliza a la izquierda para quitar + Descifrar PDF + Quitar la contraseña de un PDF + Cifrar PDF + Proteger con una contraseña + Introduce la contraseña del PDF + Este documento está protegido. Introduce su contraseña para continuar. + Contraseña + Desbloquear PDF + Esa contraseña no desbloqueó el PDF. Inténtalo de nuevo. + Este PDF está protegido con contraseña. + Descifrar PDF + Crea una copia desbloqueada sin modificar el original. + Selecciona el PDF protegido + Contraseña del PDF + Guardar copia desbloqueada + PDF desbloqueado guardado. + La contraseña es incorrecta. + Ver PDF + Ver PDF cifrado + Ver PDF desbloqueado + Cifrar PDF + Crea una copia protegida con contraseña sin modificar el original. + Selecciona el PDF a proteger + Crear contraseña del PDF + Confirmar contraseña + Guardar copia cifrada + PDF cifrado guardado. + Introduce una contraseña para proteger el PDF. + Las contraseñas no coinciden. + Este PDF ya está protegido con contraseña. + Este tipo de archivo aún no se puede previsualizar. + Vista previa de texto + Abrir documento + Selecciona dos o más archivos PDF para combinarlos en uno + Elige un modo y luego ejecuta una acción principal clara. + %1$d archivos seleccionados + Reordena los archivos antes de combinar. De arriba abajo = orden de salida. + Selecciona al menos 2 PDF para continuar. + Añadir archivos + Combinar ahora + Combinando… + Todas + Impares + Pares + Crea con escaneos, imágenes o páginas en blanco + Escanea o añade imágenes para empezar el borrador de tu PDF. + Página %1$d + Toca las flechas para reordenar + Crear un PDF en blanco + Define el número de páginas y genéralo al instante. + %1$d páginas + Texto a PDF avanzado + Úsalo para notas rápidas. Para documentos más cuidados, prefiere el modo escaneo/imagen. + Escribe el contenido de texto... + %1$d caracteres + Importar imágenes + Páginas en blanco + Texto avanzado + Crear PDF a partir del borrador + Crear PDF en blanco + Crear PDF de texto + Añadir imágenes + Ajustar a A4 + Tamaño original + Añade fotos para crear un PDF. + Compartir texto + Este PDF no tiene una capa de texto. + Elige un PDF para extraer su texto. + Reordenar, girar y eliminar páginas + Seleccionar todo + Girar + Arrastrar + Inicio + Herramientas + Ajustes + Cerrar + Anterior + Siguiente + Deshacer + Cambiar carpeta + Carpeta seleccionada + Guardar documento + Nombre del archivo + Documento.pdf + Escanear documento + Detecta bordes automáticamente, recorta y mejora tus documentos como un escáner profesional. + Escanear documento + Escáner no disponible: %1$s + Importar desde la galería + Funciones + Detección automática de bordes y recorte + Corrección de perspectiva + Escaneo de documentos de varias páginas + Importar desde la galería + Exportar como PDF + %1$d páginas escaneadas + 1 página escaneada + Escanear más + Añadir desde la galería + Borrar todo + Filtrar + Original + Automático + Escala de grises + Blanco y negro + Vívido + Guardando... + Guardar como PDF + ¡PDF guardado correctamente! + No se pudo guardar el PDF + Tamaño: %1$d KB + Vista previa de la página %1$d + Página %1$d + Quitar página + Imagen %1$d + PDF + Word + Excel + PowerPoint + Imágenes + TXT + Apoya a ClearPDF + ClearPDF es de código abierto en GitHub.\n¿Te gustaría darle una estrella al proyecto? + Sí, darle una estrella + Ahora no + No volver a preguntar + Algo salió mal. Inténtalo de nuevo. + No se pudo abrir este PDF. + No se pudo guardar el PDF. + Selecciona al menos 2 PDF + Se combinaron %1$d archivos -> %2$s\nGuardado en %3$s + Error al combinar. + Dividido en %1$d páginas\nGuardado en %2$s + Se extrajeron %1$d páginas\nGuardado en %2$s + Selecciona al menos una página para extraer + Error al dividir. + Error al extraer. + Añade al menos una imagen + Se creó un PDF de %1$d páginas\nGuardado en %2$s + Error en la conversión. + Selecciona al menos una imagen + El PDF en blanco necesita al menos 1 página + Introduce algún texto + No se pudo crear el archivo de salida. + No se pudo decodificar ninguna imagen. + Se creó %1$s (%2$d páginas)\nGuardado en %3$s + Error al crear el PDF. + Comprimido: %1$dKB -> %2$dKB (%3$d%% más pequeño)\nGuardado en %4$s + Error en la compresión. + No se encontró texto seleccionable. Este PDF parece un escaneo o una imagen — toca Reconocer texto para ejecutar OCR en el dispositivo. + Reconocer texto (OCR) + Reconociendo texto… (%1$d/%2$d) + Guardar como PDF con texto buscable + PDF con texto buscable guardado · %1$s + Error al extraer el texto. + No se pudo abrir el PDF. + No se pueden eliminar todas las páginas. + Se guardaron %1$d páginas\nGuardado en %2$s + Error al guardar. + No se pudo abrir el PDF. + Copia editada guardada como %1$s + No se pudo guardar el PDF editado. + Organizar páginas + Imágenes -> PDF + El escáner no está disponible en este contexto. + No se pudo iniciar el escáner. + + + %1$d/%2$d + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 75e35a6..d66ab05 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -421,7 +421,11 @@ Falha ao criar o PDF. Comprimido: %1$dKB -> %2$dKB (%3$d%% menor)\nSalvo em %4$s Falha ao comprimir. - Nenhum texto selecionável encontrado. Este PDF pode ser uma digitalização ou imagem - tente o OCR do scanner. + Nenhum texto selecionável encontrado. Este PDF parece ser uma digitalização ou imagem — toque em Reconhecer Texto para OCR no dispositivo. + Reconhecer Texto (OCR) + Reconhecendo texto… (%1$d/%2$d) + Salvar como PDF Pesquisável + PDF pesquisável salvo · %1$s Falha ao extrair o texto. Não foi possível abrir o PDF. Não é possível excluir todas as páginas. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 7fc6850..0f5b3a5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -421,7 +421,11 @@ Falha ao criar o PDF. Comprimido: %1$dKB -> %2$dKB (%3$d%% menor)\nSalvo em %4$s Falha ao comprimir. - Nenhum texto selecionável encontrado. Este PDF pode ser uma digitalização ou imagem - tente o OCR do scanner. + Nenhum texto selecionável encontrado. Este PDF parece ser uma digitalização ou imagem — toque em Reconhecer Texto para OCR no dispositivo. + Reconhecer Texto (OCR) + Reconhecendo texto… (%1$d/%2$d) + Salvar como PDF Pesquisável + PDF pesquisável salvo · %1$s Falha ao extrair o texto. Não foi possível abrir o PDF. Não é possível excluir todas as páginas. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e9dfd1d..ef01500 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -293,6 +293,7 @@ by %1$s English Portuguese + Spanish Open Share Details @@ -502,7 +503,11 @@ Failed to create PDF. Compressed: %1$dKB -> %2$dKB (%3$d%% smaller)\nSaved to %4$s Compression failed. - No selectable text found. This PDF may be a scan or image - try the scanner OCR instead. + No selectable text found. This PDF looks like a scan or image — tap Recognize Text to run on-device OCR. + Recognize Text (OCR) + Recognizing text… (%1$d/%2$d) + Save as Searchable PDF + Saved searchable PDF · %1$s Text extraction failed. Could not open PDF. Cannot delete every page. @@ -515,4 +520,7 @@ Images -> PDF Scanner is unavailable in this context. Could not start the scanner. + + + %1$d/%2$d diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 496d5ca..222e9be 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -2,4 +2,5 @@ + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c53ac3c..25b3323 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ camerax = "1.4.1" coil = "3.0.4" accompanist = "0.36.0" pdfbox = "2.0.27.0" +kotlinxCoroutines = "1.10.2" +tesseract4android = "4.9.0" [libraries] kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } @@ -36,7 +38,6 @@ androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lif # ML Kit & Scanning play-services-mlkit-scanner = { group = "com.google.android.gms", name = "play-services-mlkit-document-scanner", version.ref = "mlkit" } -play-services-mlkit-text-recognition = { group = "com.google.android.gms", name = "play-services-mlkit-text-recognition", version = "19.0.1" } camerax-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } @@ -44,6 +45,11 @@ camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } acccompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } +# OCR (fully on-device — see ocr-core module + THIRD_PARTY_NOTICES.md) +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +mlkit-text-recognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "mlkitTextRecognition" } +tesseract4android = { group = "cz.adaptech.tesseract4android", name = "tesseract4android", version.ref = "tesseract4android" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/ocr-core/build.gradle.kts b/ocr-core/build.gradle.kts new file mode 100644 index 0000000..8a3927f --- /dev/null +++ b/ocr-core/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.kyant.ocrcore" + compileSdk { + version = release(36) + } + buildToolsVersion = "36.1.0" + + defaultConfig { + minSdk = 23 + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + packaging { + // Tesseract4Android ships its own .so per ABI; nothing to exclude by default, + // but keep resources.excludes future-proof against META-INF collisions like pdf-core. + resources { + excludes += arrayOf("META-INF/LICENSE", "META-INF/LICENSE.md", "META-INF/NOTICE", "META-INF/NOTICE.md") + } + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.core) + + // Bundled (no Play Services / no network) on-device OCR — primary engine. + // See THIRD_PARTY_NOTICES.md. + implementation(libs.mlkit.text.recognition) + + // Fully open-source (Apache-2.0) offline OCR fallback for devices where the + // bundled ML Kit model fails to initialize. See THIRD_PARTY_NOTICES.md. + implementation(libs.tesseract4android) +} diff --git a/ocr-core/consumer-rules.pro b/ocr-core/consumer-rules.pro new file mode 100644 index 0000000..68db37f --- /dev/null +++ b/ocr-core/consumer-rules.pro @@ -0,0 +1,7 @@ +# ML Kit Text Recognition — keep classes touched via reflection. +-keep class com.google.mlkit.vision.text.** { *; } +-dontwarn com.google.mlkit.vision.text.** + +# Tesseract4Android — JNI-bound native API, keep the whole surface. +-keep class com.googlecode.tesseract.android.** { *; } +-dontwarn com.googlecode.tesseract.android.** diff --git a/ocr-core/proguard-rules.pro b/ocr-core/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/ocr-core/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/ocr-core/src/main/AndroidManifest.xml b/ocr-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/ocr-core/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/ocr-core/src/main/assets/tessdata/eng.traineddata b/ocr-core/src/main/assets/tessdata/eng.traineddata new file mode 100644 index 0000000..bbef467 Binary files /dev/null and b/ocr-core/src/main/assets/tessdata/eng.traineddata differ diff --git a/ocr-core/src/main/java/com/kyant/ocrcore/MlKitOcrEngine.kt b/ocr-core/src/main/java/com/kyant/ocrcore/MlKitOcrEngine.kt new file mode 100644 index 0000000..011d248 --- /dev/null +++ b/ocr-core/src/main/java/com/kyant/ocrcore/MlKitOcrEngine.kt @@ -0,0 +1,51 @@ +package com.kyant.ocrcore + +import android.graphics.Bitmap +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.Text +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Bundled ML Kit text recognizer (`com.google.mlkit:text-recognition`, NOT the + * Play-Services-backed variant) — the model ships inside the APK, so recognition + * never needs Play Services or a network call, and works even on devices without GMS. + */ +internal object MlKitOcrEngine { + private val recognizer by lazy { TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) } + + suspend fun recognize(bitmap: Bitmap): OcrPageResult { + val image = InputImage.fromBitmap(bitmap, 0) + val text = suspendCancellableCoroutine { cont -> + recognizer.process(image) + .addOnSuccessListener { result: Text -> cont.resume(result) } + .addOnFailureListener { error -> cont.resumeWithException(error) } + } + + val w = bitmap.width.toFloat().coerceAtLeast(1f) + val h = bitmap.height.toFloat().coerceAtLeast(1f) + val words = buildList { + for (block in text.textBlocks) { + for (line in block.lines) { + for (element in line.elements) { + val box = element.boundingBox ?: continue + if (element.text.isEmpty()) continue + add( + OcrWord( + text = element.text, + left = (box.left / w).coerceIn(0f, 1f), + top = (box.top / h).coerceIn(0f, 1f), + right = (box.right / w).coerceIn(0f, 1f), + bottom = (box.bottom / h).coerceIn(0f, 1f) + ) + ) + } + } + } + } + return OcrPageResult(words, engineUsed = "mlkit") + } +} diff --git a/ocr-core/src/main/java/com/kyant/ocrcore/OcrService.kt b/ocr-core/src/main/java/com/kyant/ocrcore/OcrService.kt new file mode 100644 index 0000000..f51510d --- /dev/null +++ b/ocr-core/src/main/java/com/kyant/ocrcore/OcrService.kt @@ -0,0 +1,28 @@ +package com.kyant.ocrcore + +import android.content.Context +import android.graphics.Bitmap + +/** A single recognized word, in bitmap-normalized (0..1) coordinates. */ +data class OcrWord( + val text: String, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) + +data class OcrPageResult( + val words: List, + val engineUsed: String +) + +/** + * Fully on-device text recognition. No implementation ever makes a network call — + * the ML Kit model ships inside the app (not the Play-Services-downloaded variant) + * and Tesseract's language data is bundled as an asset. + */ +interface OcrService { + /** Recognizes text in [bitmap]. Word boxes are normalized to the bitmap's own size (0..1). */ + suspend fun recognize(context: Context, bitmap: Bitmap): OcrPageResult +} diff --git a/ocr-core/src/main/java/com/kyant/ocrcore/OcrServiceImpl.kt b/ocr-core/src/main/java/com/kyant/ocrcore/OcrServiceImpl.kt new file mode 100644 index 0000000..6826584 --- /dev/null +++ b/ocr-core/src/main/java/com/kyant/ocrcore/OcrServiceImpl.kt @@ -0,0 +1,33 @@ +package com.kyant.ocrcore + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log + +/** + * Prefers the bundled ML Kit engine (higher real-world accuracy, small footprint); + * transparently falls back to the fully open-source Tesseract4Android engine if ML Kit + * fails to initialize or throws during recognition (e.g. on a device whose OEM image is + * missing pieces ML Kit's TFLite runtime needs). Both engines are 100% on-device. + */ +class OcrServiceImpl : OcrService { + + /** Sticky per-process: once ML Kit is confirmed broken on this device, stop retrying it. */ + @Volatile private var mlKitKnownBroken = false + + override suspend fun recognize(context: Context, bitmap: Bitmap): OcrPageResult { + if (!mlKitKnownBroken) { + runCatching { MlKitOcrEngine.recognize(bitmap) } + .onSuccess { return it } + .onFailure { e -> + Log.w(TAG, "ML Kit OCR unavailable, falling back to Tesseract4Android", e) + mlKitKnownBroken = true + } + } + return TesseractOcrEngine.recognize(context, bitmap) + } + + private companion object { + const val TAG = "OcrService" + } +} diff --git a/ocr-core/src/main/java/com/kyant/ocrcore/TesseractOcrEngine.kt b/ocr-core/src/main/java/com/kyant/ocrcore/TesseractOcrEngine.kt new file mode 100644 index 0000000..9bf8913 --- /dev/null +++ b/ocr-core/src/main/java/com/kyant/ocrcore/TesseractOcrEngine.kt @@ -0,0 +1,76 @@ +package com.kyant.ocrcore + +import android.content.Context +import android.graphics.Bitmap +import com.googlecode.tesseract.android.TessBaseAPI +import com.googlecode.tesseract.android.TessBaseAPI.PageIteratorLevel +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Fully open-source (Apache-2.0) offline OCR fallback used only when the bundled + * ML Kit engine fails to initialize or recognize (e.g. an OEM image stripped of the + * TFLite runtime, or a non-Latin script ML Kit's default model doesn't cover). + * Bundles `eng.traineddata` as a module asset so the fallback never needs a download. + */ +internal object TesseractOcrEngine { + private const val LANG = "eng" + + /** Copies the bundled trained-data asset into app-private storage on first use. + * Returns the data-path directory to pass to [TessBaseAPI.init] (the parent of "tessdata/"). */ + private fun ensureTrainedData(context: Context): File { + val tessdataDir = File(context.filesDir, "tesseract/tessdata").apply { mkdirs() } + val dest = File(tessdataDir, "$LANG.traineddata") + if (!dest.exists() || dest.length() == 0L) { + context.assets.open("tessdata/$LANG.traineddata").use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } + } + return tessdataDir.parentFile!! + } + + suspend fun recognize(context: Context, bitmap: Bitmap): OcrPageResult = withContext(Dispatchers.Default) { + val dataDir = ensureTrainedData(context) + val api = TessBaseAPI() + try { + check(api.init(dataDir.absolutePath, LANG)) { "Tesseract init failed for $LANG" } + api.setImage(bitmap) + api.getUTF8Text() // triggers recognition; result consumed via the iterator below + + val w = bitmap.width.toFloat().coerceAtLeast(1f) + val h = bitmap.height.toFloat().coerceAtLeast(1f) + val words = buildList { + val it = api.resultIterator + try { + it.begin() + while (!it.isAtBeginningOf(PageIteratorLevel.RIL_WORD)) { + if (!it.next(PageIteratorLevel.RIL_WORD)) return@buildList + } + do { + if (it.isAtBeginningOf(PageIteratorLevel.RIL_WORD)) { + val word = it.getUTF8Text(PageIteratorLevel.RIL_WORD)?.trim().orEmpty() + val box = it.getBoundingRect(PageIteratorLevel.RIL_WORD) + if (word.isNotEmpty() && box != null) { + add( + OcrWord( + text = word, + left = (box.left / w).coerceIn(0f, 1f), + top = (box.top / h).coerceIn(0f, 1f), + right = (box.right / w).coerceIn(0f, 1f), + bottom = (box.bottom / h).coerceIn(0f, 1f) + ) + ) + } + } + } while (it.next(PageIteratorLevel.RIL_WORD)) + } finally { + it.delete() + } + } + OcrPageResult(words, engineUsed = "tesseract") + } finally { + api.recycle() + } + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/raster/PdfRasterizer.kt b/pdf-core/src/main/java/com/kyant/pdfcore/raster/PdfRasterizer.kt index 67c50cd..f340b42 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/raster/PdfRasterizer.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/raster/PdfRasterizer.kt @@ -86,6 +86,31 @@ object PdfRasterizer { return results } + /** + * Renders a single page straight to an in-memory [Bitmap] (no file/FileProvider round-trip). + * Used to feed on-device OCR, which only needs the pixels, not a shareable image. + * + * @param dpi target render density; higher improves OCR accuracy on small text at some cost + * to speed — 200 is a reasonable OCR-quality default (vs. 150 for the export path above). + */ + fun rasterizePageBitmap(context: Context, source: Uri, pageIndex: Int, dpi: Int = 200): Bitmap? { + val pfd = context.contentResolver.openFileDescriptor(source, "r") ?: return null + return pfd.use { descriptor -> + PdfRenderer(descriptor).use { renderer -> + if (pageIndex !in 0 until renderer.pageCount) return@use null + renderer.openPage(pageIndex).use { page -> + val scale = dpi / 72f + val w = (page.width * scale).toInt().coerceAtLeast(1) + val h = (page.height * scale).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmap + } + } + } + } + /** Persist all rendered pages to the shared Pictures collection via MediaStore. */ fun exportToGallery(context: Context, pages: List, format: ImageFormat, albumName: String = "ClearPDF"): Int { var saved = 0 diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/searchable/PdfSearchableStamper.kt b/pdf-core/src/main/java/com/kyant/pdfcore/searchable/PdfSearchableStamper.kt new file mode 100644 index 0000000..13ec844 --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/searchable/PdfSearchableStamper.kt @@ -0,0 +1,87 @@ +package com.kyant.pdfcore.searchable + +import android.content.Context +import android.net.Uri +import com.kyant.pdfcore.internal.PdfBox +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.PDPageContentStream +import com.tom_roush.pdfbox.pdmodel.font.PDType1Font +import com.tom_roush.pdfbox.pdmodel.graphics.state.RenderingMode +import com.tom_roush.pdfbox.util.Matrix + +/** + * A recognized word to stamp as invisible, searchable text. Coordinates are page-normalized + * (0..1), top-left origin — the same convention [com.kyant.pdfcore.text.PdfTextBlock] and + * `com.kyant.ocrcore.OcrWord` already use. + */ +data class InvisibleWord(val text: String, val left: Float, val top: Float, val right: Float, val bottom: Float) + +/** + * Bakes OCR results into a PDF as an invisible (`Tr 3`) text layer, so the output is + * selectable/searchable/copyable in ANY PDF reader, not just ClearPDF — the same technique + * tools like OCRmyPDF use. Pure PDFBox; the caller supplies already-recognized words (see + * `com.kyant.ocrcore.OcrService`), so this object carries no OCR dependency of its own and + * pdf-core stays focused on PDF I/O. + */ +object PdfSearchableStamper { + + /** The source is never modified; the result (original content + invisible text) is written to [destinationUri]. */ + fun stamp( + context: Context, + sourceUri: Uri, + destinationUri: Uri, + wordsByPage: Map> + ) { + PdfBox.ensureInitialized(context) + val font = PDType1Font.HELVETICA + + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input).use { doc -> + for ((pageIndex, words) in wordsByPage) { + if (words.isEmpty() || pageIndex !in 0 until doc.numberOfPages) continue + val page = doc.getPage(pageIndex) + val box = page.cropBox ?: page.mediaBox ?: continue + val w = box.width + val h = box.height + val originX = box.lowerLeftX + val originY = box.lowerLeftY + if (w <= 0f || h <= 0f) continue + + PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> + words.forEach word@{ word -> + val sanitized = sanitize(word.text) + if (sanitized.isBlank()) return@word + val wordWpt = ((word.right - word.left) * w).coerceAtLeast(0.01f) + val wordHpt = ((word.bottom - word.top) * h).coerceAtLeast(0.01f) + val fontSize = wordHpt.coerceIn(3f, 400f) + val rawWidth = font.getStringWidth(sanitized) / 1000f * fontSize + // Horizontally scale the glyphs to match the OCR box's measured width, so a + // reader's own "highlight the match" rectangle lines up with the scanned text. + val scaleX = if (rawWidth > 0.01f) (wordWpt / rawWidth).coerceIn(0.05f, 20f) else 1f + val x = originX + word.left * w + // Normalized `top`/`bottom` are measured from the page's visual top; PDF + // text-space is bottom-up, so flip and anchor at the word's baseline-ish bottom. + val y = originY + h - word.bottom * h + + runCatching { + cs.beginText() + cs.setRenderingMode(RenderingMode.NEITHER) + cs.setFont(font, fontSize) + cs.setTextMatrix(Matrix(scaleX, 0f, 0f, 1f, x, y)) + cs.showText(sanitized) + cs.endText() + } + } + } + } + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + doc.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + } + + /** PDFBox's WinAnsi encoding rejects unsupported glyphs; keep to Latin-1 (mirrors PdfWatermarker). */ + private fun sanitize(text: String): String = + buildString { text.forEach { ch -> append(if (ch.code in 32..255) ch else ' ') } }.trim() +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2c0004d..15a7971 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,10 +19,13 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + // Tesseract4Android (open-source offline OCR fallback) is published via JitPack. + maven { url = uri("https://jitpack.io") } } } rootProject.name = "ClearPDF" include(":backdrop") include(":pdf-core") +include(":ocr-core") include(":app")