From dc1de471ad64e9eff0cb65a9436a27ab9ec63c1a Mon Sep 17 00:00:00 2001 From: Sanjay Saravanan <75960494+sanjayms01@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:35:07 -0700 Subject: [PATCH 1/7] MWPW-191927 Implementing Config-Based A/B Testing (#756) --- test/utils/experiment-provider.test.js | 2 +- .../workflow-acrobat/action-binder.js | 58 +++++++++++++------ unitylibs/scripts/utils.js | 13 +++++ unitylibs/utils/experiment-provider.js | 14 ++--- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/test/utils/experiment-provider.test.js b/test/utils/experiment-provider.test.js index 3a397cacd..25f1378f2 100644 --- a/test/utils/experiment-provider.test.js +++ b/test/utils/experiment-provider.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-underscore-dangle */ import { expect } from '@esm-bundle/chai'; -import { getExperimentData, getDecisionScopesForVerb } from '../../unitylibs/utils/experiment-provider.js'; +import getExperimentData, { getDecisionScopesForVerb } from '../../unitylibs/utils/experiment-provider.js'; describe('getExperimentData', () => { // Helper function to setup mock with result and error diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js index 98f8b6247..8b91a5e94 100644 --- a/unitylibs/core/workflow/workflow-acrobat/action-binder.js +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -164,7 +164,7 @@ export default class ActionBinder { this.actionMap = actionMap; this.limits = {}; this.operations = []; - this.acrobatApiConfig = this.getAcrobatApiConfig(); + this.acrobatApiConfig = null; this.networkUtils = new NetworkUtils(); this.uploadHandler = null; this.splashScreenEl = null; @@ -185,6 +185,9 @@ export default class ActionBinder { this.multiFileValidationFailure = false; this.initialize(); this.experimentData = null; + this.experimentViaPageConfig = false; + this.pageConfigLocation = null; + this.pageConfigFetched = false; } async initialize() { @@ -227,12 +230,14 @@ export default class ActionBinder { } getAcrobatApiConfig() { + const base = this.pageConfigLocation ? `${this.pageConfigLocation}/api/v1` : unityConfig.apiEndPoint; unityConfig.acrobatEndpoint = { - createAsset: `${unityConfig.apiEndPoint}/asset`, - finalizeAsset: `${unityConfig.apiEndPoint}/asset/finalize`, - getMetadata: `${unityConfig.apiEndPoint}/asset/metadata`, - directUpload: `${unityConfig.apiEndPoint}/asset/upload`, + createAsset: `${base}/asset`, + finalizeAsset: `${base}/asset/finalize`, + getMetadata: `${base}/asset/metadata`, + directUpload: `${base}/asset/upload`, }; + unityConfig.connectorApiEndPoint = `${base}/asset/connector`; return unityConfig; } @@ -246,18 +251,6 @@ export default class ActionBinder { } async handlePreloads() { - if (!this.experimentData && this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { - const { getExperimentData, getDecisionScopesForVerb } = await import('../../../utils/experiment-provider.js'); - try { - const decisionScopes = await getDecisionScopesForVerb(this.workflowCfg.enabledFeatures[0]); - this.experimentData = await getExperimentData(decisionScopes); - } catch (error) { - await this.dispatchErrorToast('warn_fetch_experiment', null, error.message, true, true, { - code: 'warn_fetch_experiment', - desc: error.message, - }); - } - } const parr = []; if (this.workflowCfg.targetCfg.showSplashScreen) { parr.push( @@ -267,6 +260,32 @@ export default class ActionBinder { await priorityLoad(parr); } + async ensurePageConfig() { + if (this.pageConfigFetched) return; + this.pageConfigFetched = true; + const verb = this.workflowCfg.enabledFeatures[0]; + try { + const { fetchPageConfig } = await import('../../../scripts/utils.js'); + const { default: getExperimentData } = await import('../../../utils/experiment-provider.js'); + const pageConfig = await fetchPageConfig({ product: 'acrobat', verb }); + this.pageConfigLocation = pageConfig.location; + if (pageConfig.config?.target?.enabled) { + this.experimentData = await getExperimentData(pageConfig.config.target.decisionScopes); + this.experimentViaPageConfig = true; + } else if (!this.experimentData && this.workflowCfg.targetCfg?.experimentationOn?.includes(verb)) { + const { getDecisionScopesForVerb } = await import('../../../utils/experiment-provider.js'); + const decisionScopes = await getDecisionScopesForVerb(verb); + this.experimentData = await getExperimentData(decisionScopes); + } + } catch (error) { + await this.dispatchErrorToast('warn_fetch_experiment', null, error.message, true, true, { + code: 'warn_fetch_experiment', + desc: error.message, + }); + } + this.acrobatApiConfig = this.getAcrobatApiConfig(); + } + async dispatchErrorToast(errorType, status, info = null, lanaOnly = false, showError = true, errorMetaData = {}) { if (!showError) return; const errorMessage = errorType in this.workflowCfg.errors @@ -461,7 +480,7 @@ export default class ActionBinder { redirectUrl = url.href; } } - this.redirectUrl = redirectUrl; + this.redirectUrl = redirectUrl; }) .catch(async (e) => { await this.showTransitionScreen(); @@ -490,7 +509,7 @@ export default class ActionBinder { if (this.multiFileValidationFailure) cOpts.payload.feedback = 'uploaderror'; if (this.showInfoToast) cOpts.payload.feedback = 'nonpdf'; } - if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0]) && this.experimentData) { + if (this.experimentData && (this.experimentViaPageConfig || this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0]))) { cOpts.payload.variationId = this.experimentData.variationId; } await this.getRedirectUrl(cOpts); @@ -560,6 +579,7 @@ export default class ActionBinder { if (prevalidatedFiles.length === 0) return; const { isValid, validFiles } = await this.validateFiles(prevalidatedFiles); if (!isValid) return; + await this.ensurePageConfig(); await this.initUploadHandler(); if (files.length === 1 || (validFiles.length === 1 && !verbsWithoutFallback.includes(this.workflowCfg.enabledFeatures[0]))) { await this.handleSingleFileUpload(validFiles); diff --git a/unitylibs/scripts/utils.js b/unitylibs/scripts/utils.js index 6e023f25c..8e573d33c 100644 --- a/unitylibs/scripts/utils.js +++ b/unitylibs/scripts/utils.js @@ -307,12 +307,14 @@ export const unityConfig = (() => { prod: { apiEndPoint: 'https://unity.adobe.io/api/v1', connectorApiEndPoint: 'https://unity.adobe.io/api/v1/asset/connector', + pageConfigEndPoint: 'https://cdn-unity.adobe.com/api/v1/pageConfig', env: 'prod', ...commoncfg, }, stage: { apiEndPoint: 'https://unity-stage.adobe.io/api/v1', connectorApiEndPoint: 'https://unity-stage.adobe.io/api/v1/asset/connector', + pageConfigEndPoint: 'https://cdn-unity.stage.adobe.com/api/v1/pageConfig', env: 'stage', ...commoncfg, }, @@ -345,3 +347,14 @@ export function sendAnalyticsEvent(event) { export function getMatchedDomain(domainMap = {}, hostname = window.location.hostname) { return Object.keys(domainMap).find((domain) => domainMap[domain].some((pattern) => new RegExp(pattern).test(hostname))); } + +export async function fetchPageConfig({ product, verb }) { + try { + const url = `${unityConfig.pageConfigEndPoint}?product=${product}&verb=${verb}`; + const resp = await fetch(url, { headers: { 'x-api-key': unityConfig.apiKey } }); + if (!resp.ok) throw new Error(`PageConfig fetch failed: ${resp.statusText}`); + return resp.json(); + } catch (e) { + return {}; + } +} diff --git a/unitylibs/utils/experiment-provider.js b/unitylibs/utils/experiment-provider.js index 013755c2c..f102ba724 100644 --- a/unitylibs/utils/experiment-provider.js +++ b/unitylibs/utils/experiment-provider.js @@ -1,11 +1,5 @@ /* eslint-disable no-underscore-dangle */ -export async function getDecisionScopesForVerb(verb) { - const region = await getRegion().catch(() => undefined); - const verbScope = `acom_unity_acrobat_${verb}`; - return region ? [`${verbScope}_${region}`, verbScope] : [verbScope]; -} - export async function getRegion() { const resp = await fetch('https://geo2.adobe.com/json/', { cache: 'no-cache' }); if (!resp.ok) throw new Error(`Failed to resolve region: ${resp.statusText}`); @@ -14,7 +8,13 @@ export async function getRegion() { return country.toLowerCase(); } -export async function getExperimentData(decisionScopes) { +export async function getDecisionScopesForVerb(verb) { + const region = await getRegion().catch(() => undefined); + const verbScope = `acom_unity_acrobat_${verb}`; + return region ? [`${verbScope}_${region}`, verbScope] : [verbScope]; +} + +export default async function getExperimentData(decisionScopes) { if (!decisionScopes || decisionScopes.length === 0) { throw new Error('No decision scopes provided for experiment data fetch'); } From 9f94dac82ef12b0eae3ca7989cb4686a7b8bbe64 Mon Sep 17 00:00:00 2001 From: Ruchika Sinha <69535463+Ruchika4@users.noreply.github.com> Date: Wed, 13 May 2026 22:24:44 -0700 Subject: [PATCH 2/7] [MWPW-194995]Fix delay with config-based A/B testing api initiation (#778) * Fix delay with config-based A/B testing api initiation Resolves: [MWPW-194995(https://jira.corp.adobe.com/browse/MWPW-194995) **Test URLs:** - Before: https://stage--da-dc--adobecom.aem.page/acrobat/online/compress-pdf?martech=off&unitylibs=stage - After: https://stage--da-dc--adobecom.aem.page/acrobat/online/compress-pdf?martech=off&unitylibs=target-test **Implementation details:** `this.pageConfigPromise = null (line 189)` initializes the field so it's always defined on the instance. `this.pageConfigPromise = this.ensurePageConfig() `(line 792, inside initActionListeners when b === this.block) fires the fetch the moment the verb-widget finishes rendering (for performance reasons), no await so it doesn't block listener setup. The promise is stored so it can be joined later. `await (this.pageConfigPromise || this.ensurePageConfig()) `(line 580, in handleFileUpload) if the widget has rendered (normal path), we await the already in flight or resolved promise; if somehow pageConfigPromise is null (edge case: upload triggered before initActionListeners ran with the block), we fall back to calling it directly. The existing pageConfigFetched guard inside ensurePageConfig still prevents double execution. --------- Co-authored-by: Arushi Gupta <65466846+arugupta1992@users.noreply.github.com> Co-authored-by: Arushi Gupta Co-authored-by: Sanjay Saravanan <75960494+sanjayms01@users.noreply.github.com> Co-authored-by: Vipul Gupta Co-authored-by: vipulg Co-authored-by: Nishant Thakur Co-authored-by: Ratko Zagorac <90400759+zagi25@users.noreply.github.com> Co-authored-by: Manasvi Agrawal --- unitylibs/core/workflow/workflow-acrobat/action-binder.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js index 8b91a5e94..839dd493d 100644 --- a/unitylibs/core/workflow/workflow-acrobat/action-binder.js +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -188,6 +188,7 @@ export default class ActionBinder { this.experimentViaPageConfig = false; this.pageConfigLocation = null; this.pageConfigFetched = false; + this.pageConfigPromise = null; } async initialize() { @@ -579,7 +580,7 @@ export default class ActionBinder { if (prevalidatedFiles.length === 0) return; const { isValid, validFiles } = await this.validateFiles(prevalidatedFiles); if (!isValid) return; - await this.ensurePageConfig(); + await (this.pageConfigPromise || this.ensurePageConfig()); await this.initUploadHandler(); if (files.length === 1 || (validFiles.length === 1 && !verbsWithoutFallback.includes(this.workflowCfg.enabledFeatures[0]))) { await this.handleSingleFileUpload(validFiles); @@ -811,6 +812,7 @@ export default class ActionBinder { } if (b === this.block) { this.loadTransitionScreen(); + this.pageConfigPromise = this.ensurePageConfig(); } } } From d9e7e133c51ef14105ee5910aac6973304eac78e Mon Sep 17 00:00:00 2001 From: Ruchika Sinha <69535463+Ruchika4@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:41:45 -0700 Subject: [PATCH 3/7] [MWPW-196557]Onboard resume builder (#796) * Onboard resume builder Resolves: [MWPW-196557](https://jira.corp.adobe.com/browse/MWPW-196557) **Test URLs:** - Before: https://mwpw-196281--da-dc--adobecom.aem.page/acrobat/online/test/unity/resume-builder-copy?martech=off&unitylibs=stage - After: https://mwpw-196281--da-dc--adobecom.aem.page/acrobat/online/test/unity/resume-builder-copy?martech=off&unitylibs=resume-builder Note: his has to be tested once unity backend is also on stage --------- Co-authored-by: Ruchika Sinha --- .../workflow-acrobat/action-binder.test.js | 181 ++++++++++++++++++ .../workflow-acrobat/action-binder.js | 1 + .../workflow/workflow-acrobat/limits.json | 15 ++ unitylibs/core/workflow/workflow.js | 1 + 4 files changed, 198 insertions(+) diff --git a/test/core/workflow/workflow-acrobat/action-binder.test.js b/test/core/workflow/workflow-acrobat/action-binder.test.js index 7dc4a7ba4..8dd645d9c 100644 --- a/test/core/workflow/workflow-acrobat/action-binder.test.js +++ b/test/core/workflow/workflow-acrobat/action-binder.test.js @@ -1060,6 +1060,21 @@ describe('ActionBinder', () => { expect(result).to.be.true; }); + it('should handle redirect for returning user for resume-builder', async () => { + actionBinder.workflowCfg.enabledFeatures = ['resume-builder']; + localStorage.setItem('unity.user', 'test-user'); + localStorage.setItem('resume-builder_attempts', '2'); + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.newUser).to.be.false; + expect(cOpts.payload.attempts).to.equal('2+'); + expect(actionBinder.getRedirectUrl.calledWith(cOpts)).to.be.true; + expect(result).to.be.true; + }); + it('should handle redirect with feedback for multi-file validation failure', async () => { actionBinder.multiFileValidationFailure = true; const cOpts = { payload: {} }; @@ -1693,6 +1708,64 @@ describe('ActionBinder', () => { extractSpy.restore(); }); + it('should handle input change event with PDF file for resume-builder', async () => { + const el = document.createElement('input'); + el.type = 'file'; + const addEventListenerSpy = sinon.spy(el, 'addEventListener'); + const block = { querySelector: sinon.stub().returns(el) }; + const actMap = { input: 'upload' }; + const extractSpy = sinon.spy(actionBinder, 'extractFiles'); + const spy = sinon.spy(actionBinder, 'acrobatActionMaps'); + + await actionBinder.initActionListeners(block, actMap); + + const handler = addEventListenerSpy.getCalls().find((call) => call.args[0] === 'change').args[1]; + actionBinder.signedOut = false; + actionBinder.tokenError = null; + actionBinder.workflowCfg.enabledFeatures = ['resume-builder']; + + const file = new File(['test content'], 'resume.pdf', { type: 'application/pdf' }); + const event = { target: { files: [file], value: '' } }; + + await handler(event); + + expect(extractSpy.called).to.be.true; + expect(spy.called).to.be.true; + expect(spy.firstCall.args).to.deep.equal(['upload', [file], file.size, 'change']); + + spy.restore(); + extractSpy.restore(); + }); + + it('should handle input change event with Word doc file for resume-builder', async () => { + const el = document.createElement('input'); + el.type = 'file'; + const addEventListenerSpy = sinon.spy(el, 'addEventListener'); + const block = { querySelector: sinon.stub().returns(el) }; + const actMap = { input: 'upload' }; + const extractSpy = sinon.spy(actionBinder, 'extractFiles'); + const spy = sinon.spy(actionBinder, 'acrobatActionMaps'); + + await actionBinder.initActionListeners(block, actMap); + + const handler = addEventListenerSpy.getCalls().find((call) => call.args[0] === 'change').args[1]; + actionBinder.signedOut = false; + actionBinder.tokenError = null; + actionBinder.workflowCfg.enabledFeatures = ['resume-builder']; + + const file = new File(['test content'], 'resume.doc', { type: 'application/msword' }); + const event = { target: { files: [file], value: '' } }; + + await handler(event); + + expect(extractSpy.called).to.be.true; + expect(spy.called).to.be.true; + expect(spy.firstCall.args).to.deep.equal(['upload', [file], file.size, 'change']); + + spy.restore(); + extractSpy.restore(); + }); + it('should handle input element not found', async () => { const block = { querySelector: sinon.stub().returns(null) }; const actMap = { 'nonexistent-input': 'upload' }; @@ -1959,6 +2032,26 @@ describe('ActionBinder', () => { { code: 'pre_upload_error_missing_verb_config' }, )).to.be.true; }); + + it('should not dispatch error when enabledFeatures[0] is resume-builder', async () => { + actionBinder.dispatchErrorToast.resetHistory(); + actionBinder.processSingleFile = sinon.stub().resolves(); + actionBinder.processHybrid = sinon.stub().resolves(); + actionBinder.workflowCfg.enabledFeatures = ['resume-builder']; + const validFiles = [ + { name: 'resume.pdf', type: 'application/pdf', size: 1048576 }, + ]; + const totalFileSize = validFiles.reduce((sum, file) => sum + file.size, 0); + await actionBinder.acrobatActionMaps('upload', validFiles, totalFileSize, 'test-event'); + expect(actionBinder.dispatchErrorToast.neverCalledWith( + 'error_generic', + 500, + 'Invalid or missing verb configuration on Unity', + false, + true, + { code: 'pre_upload_error_missing_verb_config' }, + )).to.be.true; + }); }); }); @@ -2603,6 +2696,94 @@ describe('ActionBinder', () => { }); }); + describe('resume-builder verb configuration', () => { + it('should have correct limits configuration for resume-builder in LIMITS_MAP', () => { + const resumeLimits = ActionBinder.LIMITS_MAP['resume-builder']; + expect(resumeLimits).to.exist; + expect(resumeLimits).to.deep.equal([ + 'single', + 'allowed-filetypes-resume', + 'page-limit-10', + 'max-filesize-20-mb', + ]); + }); + + it('should configure resume-builder as single file mode', () => { + const resumeLimits = ActionBinder.LIMITS_MAP['resume-builder']; + expect(resumeLimits[0]).to.equal('single'); + }); + + it('should have allowed-filetypes-resume for resume-builder', () => { + const resumeLimits = ActionBinder.LIMITS_MAP['resume-builder']; + expect(resumeLimits).to.include('allowed-filetypes-resume'); + }); + + it('should have page-limit-10 for resume-builder', () => { + const resumeLimits = ActionBinder.LIMITS_MAP['resume-builder']; + expect(resumeLimits).to.include('page-limit-10'); + }); + + it('should have max-filesize-20-mb for resume-builder', () => { + const resumeLimits = ActionBinder.LIMITS_MAP['resume-builder']; + expect(resumeLimits).to.include('max-filesize-20-mb'); + }); + + it('should process resume-builder as single file upload', async () => { + actionBinder.workflowCfg.enabledFeatures = ['resume-builder']; + actionBinder.dispatchErrorToast = sinon.stub().resolves(); + actionBinder.handleFileUpload = sinon.stub().resolves(); + actionBinder.limits = { + maxNumFiles: 1, + allowedFileTypes: ['application/pdf', 'application/msword'], + maxFileSize: 20971520, + }; + actionBinder.loadVerbLimits = sinon.stub().resolves(actionBinder.limits); + + const files = [new File(['content'], 'resume.pdf', { type: 'application/pdf' })]; + await actionBinder.processSingleFile(files); + + expect(actionBinder.handleFileUpload.calledWith(files)).to.be.true; + }); + + it('should reject more than one file for resume-builder', async () => { + actionBinder.workflowCfg.enabledFeatures = ['resume-builder']; + actionBinder.dispatchErrorToast = sinon.stub().resolves(); + actionBinder.handleFileUpload = sinon.stub().resolves(); + actionBinder.limits = { + maxNumFiles: 1, + allowedFileTypes: ['application/pdf', 'application/msword'], + maxFileSize: 20971520, + }; + actionBinder.loadVerbLimits = sinon.stub().resolves(actionBinder.limits); + + const files = [ + new File(['content'], 'resume1.pdf', { type: 'application/pdf' }), + new File(['content'], 'resume2.pdf', { type: 'application/pdf' }), + ]; + await actionBinder.processSingleFile(files); + + expect(actionBinder.dispatchErrorToast.calledWith('validation_error_only_accept_one_file')).to.be.true; + expect(actionBinder.handleFileUpload.called).to.be.false; + }); + + it('should accept Word document for resume-builder', async () => { + actionBinder.workflowCfg.enabledFeatures = ['resume-builder']; + actionBinder.dispatchErrorToast = sinon.stub().resolves(); + actionBinder.handleFileUpload = sinon.stub().resolves(); + actionBinder.limits = { + maxNumFiles: 1, + allowedFileTypes: ['application/pdf', 'application/msword'], + maxFileSize: 20971520, + }; + actionBinder.loadVerbLimits = sinon.stub().resolves(actionBinder.limits); + + const files = [new File(['content'], 'resume.doc', { type: 'application/msword' })]; + await actionBinder.processSingleFile(files); + + expect(actionBinder.handleFileUpload.calledWith(files)).to.be.true; + }); + }); + describe('filterFilesWithPdflite', () => { beforeEach(() => { actionBinder.MULTI_FILE = false; diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js index 839dd493d..03d96b954 100644 --- a/unitylibs/core/workflow/workflow-acrobat/action-binder.js +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -79,6 +79,7 @@ export default class ActionBinder { 'quiz-maker': ['hybrid', 'allowed-filetypes-study-spaces', 'page-limit-600', 'max-numfiles-100', 'max-filesize-100-mb'], 'flashcard-maker': ['hybrid', 'allowed-filetypes-study-spaces', 'page-limit-600', 'max-numfiles-100', 'max-filesize-100-mb'], 'mindmap-maker': ['hybrid', 'allowed-filetypes-study-spaces', 'page-limit-600', 'max-numfiles-100', 'max-filesize-100-mb'], + 'resume-builder': ['single', 'allowed-filetypes-resume', 'page-limit-10', 'max-filesize-20-mb'], }; static ERROR_MAP = { diff --git a/unitylibs/core/workflow/workflow-acrobat/limits.json b/unitylibs/core/workflow/workflow-acrobat/limits.json index 4ac878d50..789fe1ff4 100644 --- a/unitylibs/core/workflow/workflow-acrobat/limits.json +++ b/unitylibs/core/workflow/workflow-acrobat/limits.json @@ -62,6 +62,11 @@ "pageLimit": { "maxNumPages": 600 } + }, + "page-limit-10": { + "pageLimit": { + "maxNumPages": 10 + } }, "max-filesize-5-mb": { "maxFileSize": 5242880 @@ -84,6 +89,9 @@ "max-numfiles-100": { "maxNumFiles": 100 }, + "max-filesize-20-mb": { + "maxFileSize": 20971520 + }, "allowed-filetypes-pdf-only": { "allowedFileTypes": ["application/pdf"] }, @@ -176,5 +184,12 @@ "text/plain", "text/vtt" ] + }, + "allowed-filetypes-resume": { + "allowedFileTypes": [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ] } } diff --git a/unitylibs/core/workflow/workflow.js b/unitylibs/core/workflow/workflow.js index 93af64e0c..fed4a8659 100644 --- a/unitylibs/core/workflow/workflow.js +++ b/unitylibs/core/workflow/workflow.js @@ -251,6 +251,7 @@ class WfInitiator { 'quiz-maker', 'flashcard-maker', 'mindmap-maker', + 'resume-builder', ]), }, 'workflow-ai': { From c3f0ffbdcfd9c004723eaf467f93159340f02513 Mon Sep 17 00:00:00 2001 From: Ruchika Sinha <69535463+Ruchika4@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:56:53 -0700 Subject: [PATCH 4/7] Allow word doc file for resume-builder verb (#803) * Allow word doc file for resume-builder verb Resolves: [MWPW-NUMBER](https://jira.corp.adobe.com/browse/MWPW-NUMBER) **Test URLs:** - Before: https://stage--da-dc--adobecom.aem.page/acrobat/online/ai-resume-builder?martech=off&unitylibs=stage&app!versions=latest - After: https://stage--da-dc--adobecom.aem.page/acrobat/online/ai-resume-builder?martech=off&unitylibs=resume-builder-word-doc&app!versions=latest --- unitylibs/core/workflow/workflow-acrobat/target-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unitylibs/core/workflow/workflow-acrobat/target-config.json b/unitylibs/core/workflow/workflow-acrobat/target-config.json index 13d075912..6b579feab 100644 --- a/unitylibs/core/workflow/workflow-acrobat/target-config.json +++ b/unitylibs/core/workflow/workflow-acrobat/target-config.json @@ -18,7 +18,7 @@ "directUploadVerbs": ["word-to-pdf"], "directUploadMaxSize": 1048576, "nonpdfMfuFeedbackScreenTypeNonpdf": ["combine-pdf"], - "nonpdfSfuProductScreen": ["word-to-pdf", "jpg-to-pdf", "ppt-to-pdf", "excel-to-pdf", "png-to-pdf", "createpdf", "chat-pdf", "chat-pdf-student", "summarize-pdf", "pdf-ai", "heic-to-pdf", "quiz-maker", "flashcard-maker", "mindmap-maker"], + "nonpdfSfuProductScreen": ["word-to-pdf", "jpg-to-pdf", "ppt-to-pdf", "excel-to-pdf", "png-to-pdf", "createpdf", "chat-pdf", "chat-pdf-student", "summarize-pdf", "pdf-ai", "heic-to-pdf", "quiz-maker", "flashcard-maker", "mindmap-maker", "resume-builder"], "mfuUploadAllowed": ["combine-pdf", "rotate-pages", "chat-pdf", "chat-pdf-student", "summarize-pdf", "pdf-ai", "quiz-maker", "flashcard-maker", "mindmap-maker"], "mfuUploadOnlyPdfAllowed": ["combine-pdf"], "experimentationOn": ["add-comment"], From 446a477b73dd92ce62b8c96bcc807aa7b95985c8 Mon Sep 17 00:00:00 2001 From: Ruchika Sinha <69535463+Ruchika4@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:45:46 -0700 Subject: [PATCH 5/7] [MWPW-198023] Add page count check for word doc for resume builder (#805) * Add page count check for word doc for resume builder Resolves: [MWPW-198023](https://jira.corp.adobe.com/browse/MWPW-198023) **Test URLs:** - Before: https://stage--da-dc--adobecom.aem.page/acrobat/online/ai-resume-builder?unitylibs=stage - After: https://stage--da-dc--adobecom.aem.page/acrobat/online/ai-resume-builder?unitylibs=page-count --------- Co-authored-by: Ruchika Sinha --- test/unitylibs/scripts/doc-validator.test.js | 145 +++++++++++++++ test/unitylibs/scripts/docx-validator.test.js | 170 ++++++++++++++++++ .../workflow-acrobat/action-binder.js | 29 ++- unitylibs/scripts/doc-validator.js | 143 +++++++++++++++ unitylibs/scripts/docx-validator.js | 71 ++++++++ 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 test/unitylibs/scripts/doc-validator.test.js create mode 100644 test/unitylibs/scripts/docx-validator.test.js create mode 100644 unitylibs/scripts/doc-validator.js create mode 100644 unitylibs/scripts/docx-validator.js diff --git a/test/unitylibs/scripts/doc-validator.test.js b/test/unitylibs/scripts/doc-validator.test.js new file mode 100644 index 000000000..493750415 --- /dev/null +++ b/test/unitylibs/scripts/doc-validator.test.js @@ -0,0 +1,145 @@ +import { expect } from '@esm-bundle/chai'; +import { getDocPageCount } from '../../../unitylibs/scripts/doc-validator.js'; + +const DOC_MIME = 'application/msword'; +const SECTOR_SIZE = 512; +const ENDOFCHAIN = 0xFFFFFFFE; + +// Builds a minimal OLE2 compound file containing a SummaryInformation stream +// with a PID_PAGECOUNT (0x0E) property. miniCutoff is set to 0 so the stream +// lives in a regular sector rather than the mini stream, keeping the layout simple. +function buildDocBuffer(pageCount) { + // SummaryInfo property set (padded to one sector) + const si = new Uint8Array(SECTOR_SIZE); + const siv = new DataView(si.buffer); + siv.setUint16(0, 0xfffe, true); // byte order + siv.setUint32(24, 1, true); // cSections = 1 + siv.setUint32(44, 48, true); // section offset = 48 (right after stream header) + // Section at offset 48: + siv.setUint32(48, 24, true); // section size = 24 bytes + siv.setUint32(52, 1, true); // property count = 1 + siv.setUint32(56, 0x0e, true); // propId = PID_PAGECOUNT + siv.setUint32(60, 16, true); // propOffset = 16 (from section start) + // Property at section offset 16 (= stream offset 64): + siv.setUint32(64, 3, true); // type = VT_I4 + siv.setInt32(68, pageCount, true); // value + + // Layout: header(512) + FAT(sector 0) + Dir(sector 1) + SI stream(sector 2) + const buf = new Uint8Array(512 + 3 * SECTOR_SIZE); + const v = new DataView(buf.buffer); + + // OLE2 magic + [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1].forEach((b, i) => { buf[i] = b; }); + + // Header fields + v.setUint16(0x1e, 9, true); // sector size = 2^9 = 512 + v.setUint16(0x20, 6, true); // mini sector size = 2^6 = 64 + v.setUint32(0x2c, 1, true); // total FAT sectors = 1 + v.setUint32(0x30, 1, true); // dir start = sector 1 + v.setUint32(0x38, 0, true); // miniCutoff = 0 → all streams in regular sectors + v.setUint32(0x3c, ENDOFCHAIN, true); // no mini FAT + v.setUint32(0x40, 0, true); // total mini FAT sectors = 0 + v.setUint32(0x44, ENDOFCHAIN, true); // no DIFAT chain + v.setUint32(0x4c, 0, true); // DIFAT[0] = sector 0 (FAT) + for (let i = 1; i < 109; i++) v.setUint32(0x4c + i * 4, 0xffffffff, true); + + // FAT (sector 0, bytes 512–1023) + v.setUint32(512 + 0 * 4, 0xfffffffd, true); // sector 0 = FATSECT + v.setUint32(512 + 1 * 4, ENDOFCHAIN, true); // sector 1 = dir (ENDOFCHAIN) + v.setUint32(512 + 2 * 4, ENDOFCHAIN, true); // sector 2 = SI stream (ENDOFCHAIN) + for (let i = 3; i < SECTOR_SIZE / 4; i++) v.setUint32(512 + i * 4, 0xffffffff, true); + + // Directory (sector 1, bytes 1024–1535) — 128 bytes per entry + const dir = 1024; + + // Entry 0: Root Entry (type = 5) + const rootName = [...'Root Entry\0'].flatMap((c) => [c.charCodeAt(0), 0]); + buf.set(rootName, dir); + v.setUint16(dir + 64, rootName.length, true); + buf[dir + 66] = 5; // root storage + buf[dir + 67] = 1; + v.setUint32(dir + 68, 0xffffffff, true); // left sibling + v.setUint32(dir + 72, 0xffffffff, true); // right sibling + v.setUint32(dir + 76, 1, true); // child = entry 1 + v.setUint32(dir + 116, ENDOFCHAIN, true); // no mini stream + v.setUint32(dir + 120, 0, true); + + // Entry 1: \x05SummaryInformation (type = 2, stream) + const siName = [...'\x05SummaryInformation\0'].flatMap((c) => [c.charCodeAt(0), 0]); + const e1 = dir + 128; + buf.set(siName, e1); + v.setUint16(e1 + 64, siName.length, true); + buf[e1 + 66] = 2; // stream + buf[e1 + 67] = 1; + v.setUint32(e1 + 68, 0xffffffff, true); // left sibling + v.setUint32(e1 + 72, 0xffffffff, true); // right sibling + v.setUint32(e1 + 76, 0xffffffff, true); // no child + v.setUint32(e1 + 116, 2, true); // start = sector 2 + v.setUint32(e1 + 120, 72, true); // size = 72 bytes (the property set) + + // SI stream (sector 2, bytes 1536–2047) + buf.set(si, 1536); + + return buf.buffer; +} + +function makeDocFile(pageCount) { + return new File([buildDocBuffer(pageCount)], 'resume.doc', { type: DOC_MIME }); +} + +describe('doc-validator', () => { + describe('getDocPageCount', () => { + it('returns null for non-doc files', async () => { + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + expect(await getDocPageCount(file)).to.be.null; + }); + + it('extracts page count from a valid doc file', async () => { + expect(await getDocPageCount(makeDocFile(5))).to.equal(5); + }); + + it('returns null for a corrupt (non-OLE2) doc file', async () => { + const file = new File([new Uint8Array([0x00, 0x01, 0x02])], 'bad.doc', { type: DOC_MIME }); + expect(await getDocPageCount(file)).to.be.null; + }); + + it('returns null when SummaryInformation stream is absent', async () => { + // Build a valid OLE2 file but with no SummaryInformation entry + const buf = new Uint8Array(512 + 2 * SECTOR_SIZE); + const v = new DataView(buf.buffer); + [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1].forEach((b, i) => { buf[i] = b; }); + v.setUint16(0x1e, 9, true); + v.setUint16(0x20, 6, true); + v.setUint32(0x2c, 1, true); + v.setUint32(0x30, 1, true); + v.setUint32(0x38, 0, true); + v.setUint32(0x3c, ENDOFCHAIN, true); + v.setUint32(0x4c, 0, true); + for (let i = 1; i < 109; i++) v.setUint32(0x4c + i * 4, 0xffffffff, true); + v.setUint32(512 + 0 * 4, 0xfffffffd, true); + v.setUint32(512 + 1 * 4, ENDOFCHAIN, true); + for (let i = 2; i < SECTOR_SIZE / 4; i++) v.setUint32(512 + i * 4, 0xffffffff, true); + // Root entry only, no SummaryInformation child + const rootName = [...'Root Entry\0'].flatMap((c) => [c.charCodeAt(0), 0]); + buf.set(rootName, 1024); + v.setUint16(1024 + 64, rootName.length, true); + buf[1024 + 66] = 5; + v.setUint32(1024 + 68, 0xffffffff, true); + v.setUint32(1024 + 72, 0xffffffff, true); + v.setUint32(1024 + 76, 0xffffffff, true); // no child + v.setUint32(1024 + 116, ENDOFCHAIN, true); + const file = new File([buf.buffer], 'no-si.doc', { type: DOC_MIME }); + expect(await getDocPageCount(file)).to.be.null; + }); + + it('returns null when Pages property is missing from the stream', async () => { + // Build a valid OLE2 file with a SummaryInfo stream that has 0 properties + const buf = buildDocBuffer(0); + const v = new DataView(buf); + // Overwrite propCount to 0 at sectionOffset(48) + 4 = stream offset 1536+52 + v.setUint32(1536 + 52, 0, true); + const file = new File([buf], 'no-pages.doc', { type: DOC_MIME }); + expect(await getDocPageCount(file)).to.be.null; + }); + }); +}); diff --git a/test/unitylibs/scripts/docx-validator.test.js b/test/unitylibs/scripts/docx-validator.test.js new file mode 100644 index 000000000..caa9316d4 --- /dev/null +++ b/test/unitylibs/scripts/docx-validator.test.js @@ -0,0 +1,170 @@ +import { expect } from '@esm-bundle/chai'; +import { getDocxPageCount } from '../../../unitylibs/scripts/docx-validator.js'; + +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +// Builds a minimal valid ZIP containing one stored (uncompressed) entry. +function buildZipWithEntry(filename, content) { + const encoder = new TextEncoder(); + const filenameBytes = encoder.encode(filename); + const contentBytes = encoder.encode(content); + + // Local file header + const localHeader = new Uint8Array(30 + filenameBytes.length); + const localView = new DataView(localHeader.buffer); + localView.setUint32(0, 0x04034b50, true); // signature + localView.setUint16(4, 20, true); + localView.setUint16(6, 0, true); + localView.setUint16(8, 0, true); // compression: stored + localView.setUint16(10, 0, true); + localView.setUint16(12, 0, true); + localView.setUint32(14, 0, true); // crc32 (not validated) + localView.setUint32(18, contentBytes.length, true); + localView.setUint32(22, contentBytes.length, true); + localView.setUint16(26, filenameBytes.length, true); + localView.setUint16(28, 0, true); + localHeader.set(filenameBytes, 30); + + // Central directory header + const cdHeader = new Uint8Array(46 + filenameBytes.length); + const cdView = new DataView(cdHeader.buffer); + cdView.setUint32(0, 0x02014b50, true); // signature + cdView.setUint16(4, 20, true); + cdView.setUint16(6, 20, true); + cdView.setUint16(8, 0, true); + cdView.setUint16(10, 0, true); // compression: stored + cdView.setUint16(12, 0, true); + cdView.setUint16(14, 0, true); + cdView.setUint32(16, 0, true); + cdView.setUint32(20, contentBytes.length, true); + cdView.setUint32(24, contentBytes.length, true); + cdView.setUint16(28, filenameBytes.length, true); + cdView.setUint16(30, 0, true); + cdView.setUint16(32, 0, true); + cdView.setUint16(34, 0, true); + cdView.setUint16(36, 0, true); + cdView.setUint32(38, 0, true); + cdView.setUint32(42, 0, true); // local header offset + cdHeader.set(filenameBytes, 46); + + const cdOffset = localHeader.length + contentBytes.length; + + // End of central directory + const eocd = new Uint8Array(22); + const eocdView = new DataView(eocd.buffer); + eocdView.setUint32(0, 0x06054b50, true); // signature + eocdView.setUint16(4, 0, true); + eocdView.setUint16(6, 0, true); + eocdView.setUint16(8, 1, true); + eocdView.setUint16(10, 1, true); + eocdView.setUint32(12, cdHeader.length, true); + eocdView.setUint32(16, cdOffset, true); + eocdView.setUint16(20, 0, true); + + const total = localHeader.length + contentBytes.length + cdHeader.length + eocd.length; + const zip = new Uint8Array(total); + let off = 0; + zip.set(localHeader, off); off += localHeader.length; + zip.set(contentBytes, off); off += contentBytes.length; + zip.set(cdHeader, off); off += cdHeader.length; + zip.set(eocd, off); + return zip.buffer; +} + +function makeDocxFile(pageCount) { + const xml = `${pageCount}`; + const buffer = buildZipWithEntry('docProps/app.xml', xml); + return new File([buffer], 'resume.docx', { type: DOCX_MIME }); +} + +async function deflate(data) { + const cs = new CompressionStream('deflate-raw'); + const input = new ReadableStream({ start(c) { c.enqueue(data); c.close(); } }); + return new Uint8Array(await new Response(input.pipeThrough(cs)).arrayBuffer()); +} + +async function buildZipWithDeflateEntry(filename, content) { + const filenameBytes = new TextEncoder().encode(filename); + const contentBytes = new TextEncoder().encode(content); + const compressed = await deflate(contentBytes); + + const localHeader = new Uint8Array(30 + filenameBytes.length); + const lv = new DataView(localHeader.buffer); + lv.setUint32(0, 0x04034b50, true); + lv.setUint16(4, 20, true); + lv.setUint16(8, 8, true); // deflate + lv.setUint32(18, compressed.length, true); + lv.setUint32(22, contentBytes.length, true); + lv.setUint16(26, filenameBytes.length, true); + localHeader.set(filenameBytes, 30); + + const cdHeader = new Uint8Array(46 + filenameBytes.length); + const cv = new DataView(cdHeader.buffer); + cv.setUint32(0, 0x02014b50, true); + cv.setUint16(4, 20, true); + cv.setUint16(6, 20, true); + cv.setUint16(10, 8, true); // deflate + cv.setUint32(20, compressed.length, true); + cv.setUint32(24, contentBytes.length, true); + cv.setUint16(28, filenameBytes.length, true); + cv.setUint32(42, 0, true); // local header offset + cdHeader.set(filenameBytes, 46); + + const cdOffset = localHeader.length + compressed.length; + const eocd = new Uint8Array(22); + const ev = new DataView(eocd.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(8, 1, true); + ev.setUint16(10, 1, true); + ev.setUint32(12, cdHeader.length, true); + ev.setUint32(16, cdOffset, true); + + const zip = new Uint8Array(localHeader.length + compressed.length + cdHeader.length + eocd.length); + let off = 0; + zip.set(localHeader, off); off += localHeader.length; + zip.set(compressed, off); off += compressed.length; + zip.set(cdHeader, off); off += cdHeader.length; + zip.set(eocd, off); + return zip.buffer; +} + +async function makeDeflateDocxFile(pageCount) { + const xml = `${pageCount}`; + const buffer = await buildZipWithDeflateEntry('docProps/app.xml', xml); + return new File([buffer], 'resume.docx', { type: DOCX_MIME }); +} + +describe('docx-validator', () => { + describe('getDocxPageCount', () => { + it('returns null for non-docx files', async () => { + const file = new File(['data'], 'test.pdf', { type: 'application/pdf' }); + expect(await getDocxPageCount(file)).to.be.null; + }); + + it('extracts page count from a valid docx file', async () => { + expect(await getDocxPageCount(makeDocxFile(5))).to.equal(5); + }); + + it('returns null when docProps/app.xml is missing from the zip', async () => { + const buffer = buildZipWithEntry('word/document.xml', ''); + const file = new File([buffer], 'resume.docx', { type: DOCX_MIME }); + expect(await getDocxPageCount(file)).to.be.null; + }); + + it('returns null when Pages tag is absent from app.xml', async () => { + const buffer = buildZipWithEntry('docProps/app.xml', '100'); + const file = new File([buffer], 'resume.docx', { type: DOCX_MIME }); + expect(await getDocxPageCount(file)).to.be.null; + }); + + it('extracts page count from a deflate-compressed entry (as real DOCX files use)', async () => { + const file = await makeDeflateDocxFile(7); + expect(await getDocxPageCount(file)).to.equal(7); + }); + + it('returns null for a corrupt (non-zip) docx file', async () => { + const file = new File([new Uint8Array([0x00, 0x01, 0x02])], 'bad.docx', { type: DOCX_MIME }); + expect(await getDocxPageCount(file)).to.be.null; + }); + }); +}); diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js index 03d96b954..7f2ff245f 100644 --- a/unitylibs/core/workflow/workflow-acrobat/action-binder.js +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -569,6 +569,29 @@ export default class ActionBinder { } } + async validateWordFilePageCount(files) { + if (!this.limits.pageLimit?.maxNumPages || files.length === 0) return files; + try { + const file = files[0]; + let pageCount = null; + if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + const { getDocxPageCount } = await import('../../../scripts/docx-validator.js'); + pageCount = await getDocxPageCount(file); + } else if (file.type === 'application/msword') { + const { getDocPageCount } = await import('../../../scripts/doc-validator.js'); + pageCount = await getDocPageCount(file); + } + if (pageCount !== null && pageCount > this.limits.pageLimit.maxNumPages) { + const errorCode = ActionBinder.SINGLE_FILE_ERROR_MESSAGES.OVER_MAX_PAGE_COUNT; + await this.dispatchErrorToast(errorCode, null, null, false, true, { code: errorCode }); + return []; + } + } catch (error) { + await this.dispatchErrorToast('error_generic', 500, `Exception during Word page count validation: ${error.message}`, true); + } + return files; + } + async handleFileUpload(files) { const verbsWithoutFallback = this.workflowCfg.targetCfg.verbsWithoutMfuToSfuFallback; const sanitizedFiles = await Promise.all(files.map(async (file) => { @@ -579,7 +602,11 @@ export default class ActionBinder { this.MULTI_FILE = files.length > 1; const prevalidatedFiles = await this.filterFilesWithPdflite(sanitizedFiles); if (prevalidatedFiles.length === 0) return; - const { isValid, validFiles } = await this.validateFiles(prevalidatedFiles); + const wordValidatedFiles = this.workflowCfg.enabledFeatures[0] === 'resume-builder' + ? await this.validateWordFilePageCount(prevalidatedFiles) + : prevalidatedFiles; + if (wordValidatedFiles.length === 0) return; + const { isValid, validFiles } = await this.validateFiles(wordValidatedFiles); if (!isValid) return; await (this.pageConfigPromise || this.ensurePageConfig()); await this.initUploadHandler(); diff --git a/unitylibs/scripts/doc-validator.js b/unitylibs/scripts/doc-validator.js new file mode 100644 index 000000000..bead6dc6a --- /dev/null +++ b/unitylibs/scripts/doc-validator.js @@ -0,0 +1,143 @@ +const DOC_MIME = 'application/msword'; +const OLE2_MAGIC = new Uint8Array([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); +const ENDOFCHAIN = 0xFFFFFFFE; +const PID_PAGECOUNT = 0x0E; +const VT_I4 = 3; + +function fatChain(fat, start) { + const sectors = []; + let s = start; + while (s < ENDOFCHAIN && sectors.length <= fat.length) { + sectors.push(s); + s = fat[s]; + } + return sectors; +} + +function readSectors(bytes, fat, start, size, sectorSize) { + const out = new Uint8Array(size); + let pos = 0; + for (const s of fatChain(fat, start)) { + const base = (s + 1) * sectorSize; + const n = Math.min(sectorSize, size - pos); + out.set(bytes.slice(base, base + n), pos); + pos += n; + if (pos >= size) break; + } + return out; +} + +function readMiniSectors(mini, miniFat, start, size, miniSectorSize) { + const out = new Uint8Array(size); + let pos = 0; + for (const s of fatChain(miniFat, start)) { + const base = s * miniSectorSize; + const n = Math.min(miniSectorSize, size - pos); + out.set(mini.slice(base, base + n), pos); + pos += n; + if (pos >= size) break; + } + return out; +} + +export async function getDocPageCount(file) { + if (file.type !== DOC_MIME) return null; + try { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + + if (!OLE2_MAGIC.every((b, i) => bytes[i] === b)) return null; + + const sectorSize = 1 << view.getUint16(0x1E, true); + const miniSectorSize = 1 << view.getUint16(0x20, true); + const totalFatSectors = view.getUint32(0x2C, true); + const dirStart = view.getUint32(0x30, true); + const miniCutoff = view.getUint32(0x38, true); + const miniFatStart = view.getUint32(0x3C, true); + + const fatSectors = []; + for (let i = 0; i < 109; i++) { + const s = view.getUint32(0x4C + i * 4, true); + if (s >= ENDOFCHAIN) break; + fatSectors.push(s); + } + const difatStart = view.getUint32(0x44, true); + if (difatStart < ENDOFCHAIN) { + const slotsPerDifat = sectorSize / 4 - 1; + let difatSector = difatStart; + while (difatSector < ENDOFCHAIN) { + const base = (difatSector + 1) * sectorSize; + for (let j = 0; j < slotsPerDifat; j++) { + const s = view.getUint32(base + j * 4, true); + if (s >= ENDOFCHAIN) break; + fatSectors.push(s); + } + difatSector = view.getUint32(base + slotsPerDifat * 4, true); + } + } + const fat = new Uint32Array(totalFatSectors * (sectorSize / 4)); + let fatIdx = 0; + for (const fatSector of fatSectors) { + const base = (fatSector + 1) * sectorSize; + for (let j = 0; j < sectorSize / 4; j++) { + fat[fatIdx++] = view.getUint32(base + j * 4, true); + } + } + + const entries = []; + for (const s of fatChain(fat, dirStart)) { + const base = (s + 1) * sectorSize; + for (let i = 0; i < sectorSize / 128; i++) { + const eb = base + i * 128; + const nameLen = view.getUint16(eb + 64, true); + if (nameLen < 2) { entries.push(null); continue; } + const name = new TextDecoder('utf-16le').decode(bytes.slice(eb, eb + nameLen - 2)); + entries.push({ + name, + type: bytes[eb + 66], + start: view.getUint32(eb + 116, true), + size: view.getUint32(eb + 120, true), + }); + } + } + + const root = entries[0]; + if (!root) return null; + + let miniStream = null; + let miniFat = null; + if (miniFatStart < ENDOFCHAIN) { + const mfatArr = []; + for (const s of fatChain(fat, miniFatStart)) { + const base = (s + 1) * sectorSize; + for (let j = 0; j < sectorSize / 4; j++) mfatArr.push(view.getUint32(base + j * 4, true)); + } + miniFat = new Uint32Array(mfatArr); + miniStream = readSectors(bytes, fat, root.start, root.size, sectorSize); + } + + const si = entries.find((e) => e?.name === 'SummaryInformation'); + if (!si) return null; + + const siData = si.size < miniCutoff && miniStream && miniFat + ? readMiniSectors(miniStream, miniFat, si.start, si.size, miniSectorSize) + : readSectors(bytes, fat, si.start, si.size, sectorSize); + + const sv = new DataView(siData.buffer); + const sectionOffset = sv.getUint32(44, true); + const propCount = sv.getUint32(sectionOffset + 4, true); + for (let i = 0; i < propCount; i++) { + const base = sectionOffset + 8 + i * 8; + if (sv.getUint32(base, true) === PID_PAGECOUNT) { + const off = sv.getUint32(base + 4, true); + if (sv.getUint32(sectionOffset + off, true) === VT_I4) { + return sv.getInt32(sectionOffset + off + 4, true); + } + } + } + return null; + } catch { + return null; + } +} diff --git a/unitylibs/scripts/docx-validator.js b/unitylibs/scripts/docx-validator.js new file mode 100644 index 000000000..7ae048bb1 --- /dev/null +++ b/unitylibs/scripts/docx-validator.js @@ -0,0 +1,71 @@ +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +async function extractZipEntry(buffer, targetFilename) { + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + let eocdOffset = -1; + for (let i = bytes.length - 22; i >= 0; i--) { + if (view.getUint32(i, true) === 0x06054b50) { + eocdOffset = i; + break; + } + } + if (eocdOffset === -1) return null; + + const cdOffset = view.getUint32(eocdOffset + 16, true); + const cdSize = view.getUint32(eocdOffset + 12, true); + let cdPos = cdOffset; + + while (cdPos < cdOffset + cdSize) { + if (view.getUint32(cdPos, true) !== 0x02014b50) break; + + const compressionMethod = view.getUint16(cdPos + 10, true); + const compressedSize = view.getUint32(cdPos + 20, true); + const filenameLength = view.getUint16(cdPos + 28, true); + const extraLength = view.getUint16(cdPos + 30, true); + const commentLength = view.getUint16(cdPos + 32, true); + const localOffset = view.getUint32(cdPos + 42, true); + const filename = new TextDecoder().decode(bytes.slice(cdPos + 46, cdPos + 46 + filenameLength)); + + if (filename === targetFilename) { + const localExtraLength = view.getUint16(localOffset + 28, true); + const localFilenameLength = view.getUint16(localOffset + 26, true); + const dataStart = localOffset + 30 + localFilenameLength + localExtraLength; + const compressedData = bytes.slice(dataStart, dataStart + compressedSize); + + if (compressionMethod === 0) { + return new TextDecoder().decode(compressedData); + } + if (compressionMethod === 8) { + if (typeof DecompressionStream === 'undefined') return null; + const ds = new DecompressionStream('deflate-raw'); + const inputStream = new ReadableStream({ + start(controller) { + controller.enqueue(compressedData); + controller.close(); + }, + }); + const decompressed = await new Response(inputStream.pipeThrough(ds)).arrayBuffer(); + return new TextDecoder().decode(decompressed); + } + return null; + } + + cdPos += 46 + filenameLength + extraLength + commentLength; + } + return null; +} + +export async function getDocxPageCount(file) { + if (file.type !== DOCX_MIME) return null; + try { + const buffer = await file.arrayBuffer(); + const xml = await extractZipEntry(buffer, 'docProps/app.xml'); + if (!xml) return null; + const match = xml.match(/(\d+)<\/Pages>/i); + return match ? parseInt(match[1], 10) : null; + } catch { + return null; + } +} From 6f01d1f18ae54eaabd07f866fb80d43f4d7a9dbc Mon Sep 17 00:00:00 2001 From: Ruchika Sinha Date: Thu, 11 Jun 2026 13:52:29 -0700 Subject: [PATCH 6/7] add redirect query param for more reliable data for upload optimization --- unitylibs/core/workflow/workflow-acrobat/action-binder.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js index 7f2ff245f..6bdc0aa33 100644 --- a/unitylibs/core/workflow/workflow-acrobat/action-binder.js +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -706,9 +706,10 @@ export default class ActionBinder { if (getMatchedDomain(this.workflowCfg.targetCfg.domainMap) === 'acrobat') { document.cookie = `dc_fl=1;domain=.adobe.com;path=/;expires=${new Date(Date.now() + 30 * 1000).toUTCString()}`; } + const stamp = Date.now(); if (this.multiFileFailure && !this.redirectUrl.includes('feedback=') && this.redirectUrl.includes('#folder')) { window.location.href = `${baseUrl}?feedback=${this.multiFileFailure}&${queryString}`; - } else window.location.href = `${baseUrl}?${this.redirectWithoutUpload === false ? `UTS_Uploaded=${this.uploadTimestamp}&` : ''}${queryString}`; + } else window.location.href = `${baseUrl}?${this.redirectWithoutUpload === false ? `UTS_Uploaded=${this.uploadTimestamp}&` : ''}redirectTime=${stamp}&${queryString}`; } catch (e) { await this.transitionScreen.showSplashScreen(); await this.dispatchErrorToast('error_generic', 500, `Exception thrown when redirecting to product; ${e.message}`, false, e.showError, { From faa9567c104b40cbc19a69410005df71cc892e5b Mon Sep 17 00:00:00 2001 From: Ruchika Sinha Date: Thu, 11 Jun 2026 14:04:48 -0700 Subject: [PATCH 7/7] add redirect query param --- unitylibs/core/workflow/workflow-acrobat/action-binder.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js index 6bdc0aa33..8c0532e66 100644 --- a/unitylibs/core/workflow/workflow-acrobat/action-binder.js +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -706,10 +706,9 @@ export default class ActionBinder { if (getMatchedDomain(this.workflowCfg.targetCfg.domainMap) === 'acrobat') { document.cookie = `dc_fl=1;domain=.adobe.com;path=/;expires=${new Date(Date.now() + 30 * 1000).toUTCString()}`; } - const stamp = Date.now(); if (this.multiFileFailure && !this.redirectUrl.includes('feedback=') && this.redirectUrl.includes('#folder')) { - window.location.href = `${baseUrl}?feedback=${this.multiFileFailure}&${queryString}`; - } else window.location.href = `${baseUrl}?${this.redirectWithoutUpload === false ? `UTS_Uploaded=${this.uploadTimestamp}&` : ''}redirectTime=${stamp}&${queryString}`; + window.location.href = `${baseUrl}?feedback=${this.multiFileFailure}&redirectTime=${Date.now()}&${queryString}`; + } else window.location.href = `${baseUrl}?${this.redirectWithoutUpload === false ? `UTS_Uploaded=${this.uploadTimestamp}&` : ''}redirectTime=${Date.now()}&${queryString}`; } catch (e) { await this.transitionScreen.showSplashScreen(); await this.dispatchErrorToast('error_generic', 500, `Exception thrown when redirecting to product; ${e.message}`, false, e.showError, {