mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-13 11:37:13 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bbed904d8 | ||
|
|
1fd5c5db67 | ||
|
|
11ea2b5693 | ||
|
|
dff45ce08d | ||
|
|
214ea1468c | ||
|
|
9b5360959a |
No files matched your search
@@ -38,7 +38,6 @@ src/documents/bulk_edit.py:0: error: Incompatible types in assignment (expressio
|
|||||||
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
||||||
src/documents/bulk_edit.py:0: error: Missing named argument "p" for "remove" of "PageList" [call-arg]
|
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
||||||
|
|||||||
@@ -91,13 +91,6 @@
|
|||||||
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
||||||
"severity": "error"
|
"severity": "error"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"column": 33,
|
|
||||||
"path": "src/documents/bulk_edit.py",
|
|
||||||
"name": "missing-argument",
|
|
||||||
"concise_description": "Missing argument `p` in function `pikepdf._core.PageList.remove`",
|
|
||||||
"severity": "error"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"column": 25,
|
"column": 25,
|
||||||
"path": "src/documents/caching.py",
|
"path": "src/documents/caching.py",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"marked": "~18.0.14",
|
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
|
|||||||
Generated
-10
@@ -53,9 +53,6 @@ importers:
|
|||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.18.1
|
specifier: ^4.18.1
|
||||||
version: 4.18.1
|
version: 4.18.1
|
||||||
marked:
|
|
||||||
specifier: ~18.0.14
|
|
||||||
version: 18.0.14
|
|
||||||
mime-names:
|
mime-names:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -3229,11 +3226,6 @@ packages:
|
|||||||
make-error@1.3.6:
|
make-error@1.3.6:
|
||||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||||
|
|
||||||
marked@18.0.14:
|
|
||||||
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
|
|
||||||
engines: {node: '>= 20'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
material-colors@1.2.6:
|
material-colors@1.2.6:
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||||
|
|
||||||
@@ -7417,8 +7409,6 @@ snapshots:
|
|||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
|
|
||||||
marked@18.0.14: {}
|
|
||||||
|
|
||||||
material-colors@1.2.6: {}
|
material-colors@1.2.6: {}
|
||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|||||||
@@ -1,85 +1,49 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
<div class="chat-container bg-light p-2">
|
||||||
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
<div class="chat-messages font-monospace small">
|
||||||
<span class="fw-semibold small">
|
@for (message of messages(); track message) {
|
||||||
@if (documentId()) {
|
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||||
<ng-container i18n>Ask about this document</ng-container>
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
} @else {
|
<span class="text-break">
|
||||||
<ng-container i18n>Ask about your documents</ng-container>
|
{{ message.content }}
|
||||||
}
|
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||||
</span>
|
</span>
|
||||||
@if (messages().length) {
|
@if (message.role === 'assistant' && message.references?.length) {
|
||||||
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
<div class="chat-references list-group mt-3">
|
||||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
|
@for (reference of message.references; track reference.id) {
|
||||||
</button>
|
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
|
||||||
}
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
||||||
</div>
|
</a>
|
||||||
|
}
|
||||||
<div class="chat-messages small px-2">
|
</div>
|
||||||
@if (!messages().length) {
|
}
|
||||||
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
</div>
|
||||||
}
|
|
||||||
@for (message of messages(); track message) {
|
|
||||||
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
|
||||||
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
|
||||||
@if (message.isStreaming && !message.content) {
|
|
||||||
<div class="d-flex align-items-center text-muted">
|
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
||||||
@if (slowResponse()) {
|
|
||||||
<ng-container i18n>Still thinking...</ng-container>
|
|
||||||
} @else {
|
|
||||||
<ng-container i18n>Thinking...</ng-container>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else if (message.role === 'assistant') {
|
|
||||||
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
|
|
||||||
} @else {
|
|
||||||
<span class="text-break">{{ message.content }}</span>
|
|
||||||
}
|
|
||||||
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
|
||||||
<div class="chat-references list-group mt-3">
|
|
||||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
|
||||||
@for (reference of message.references; track reference.id) {
|
|
||||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
<div #scrollAnchor></div>
|
||||||
<div #scrollAnchor></div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="chat-input border-top p-2">
|
<form class="chat-input">
|
||||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
#chatInput
|
#chatInput
|
||||||
id="chatInput"
|
id="chatInput"
|
||||||
class="form-control" name="chatInput" type="text"
|
class="form-control form-control-sm" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading()"
|
[disabled]="loading()"
|
||||||
[ngModel]="input()"
|
[ngModel]="input()"
|
||||||
(ngModelChange)="input.set($event)"
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
@if (loading()) {
|
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
</div>
|
||||||
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
</form>
|
||||||
</button>
|
</div>
|
||||||
} @else {
|
|
||||||
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
|
||||||
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
width: min(36rem, calc(100vw - 20px));
|
width: var(--pngx-toast-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
max-height: min(60dvh, 36rem);
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-references {
|
||||||
|
font-family: var(--bs-font-sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -16,44 +20,23 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 400px) {
|
||||||
.chat-messages {
|
:host ::ng-deep .dropdown-menu-end {
|
||||||
max-height: calc(100dvh - 12rem);
|
right: -3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-markdown.streaming ::ng-deep {
|
.blinking-cursor {
|
||||||
> :last-child:not(ul, ol, table)::after,
|
font-weight: bold;
|
||||||
> :is(ul, ol):last-child > li:last-child::after {
|
font-size: 1.2em;
|
||||||
content: '';
|
animation: blink 1s step-end infinite;
|
||||||
display: inline-block;
|
|
||||||
width: 2px;
|
|
||||||
height: 1.05em;
|
|
||||||
margin-left: 2px;
|
|
||||||
background-color: currentColor;
|
|
||||||
vertical-align: -0.15em;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
50% {
|
from, to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
50% {
|
||||||
|
opacity: 1;
|
||||||
.chat-markdown ::ng-deep {
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
border: 1px solid var(--bs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
> :last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,16 +80,8 @@ describe('ChatComponent', () => {
|
|||||||
expect(component.messages()[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading()).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
|
||||||
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
|
|
||||||
expect(messagesEl.textContent).toContain('Thinking...')
|
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages()[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
fixture.detectChanges()
|
|
||||||
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
|
||||||
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
|
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
await jest.runAllTimersAsync()
|
await jest.runAllTimersAsync()
|
||||||
@@ -104,8 +96,6 @@ describe('ChatComponent', () => {
|
|||||||
await fixture.whenStable()
|
await fixture.whenStable()
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
fixture.detectChanges()
|
|
||||||
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
|
|
||||||
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
@@ -133,10 +123,6 @@ describe('ChatComponent', () => {
|
|||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||||
)
|
)
|
||||||
fixture.detectChanges()
|
|
||||||
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
|
|
||||||
|
|
||||||
mockStream$.complete()
|
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -170,96 +156,13 @@ describe('ChatComponent', () => {
|
|||||||
component.input.set('Hello')
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next('Partial')
|
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages()[1].content).toBe(
|
expect(component.messages()[1].content).toContain(
|
||||||
'Partial\n\n⚠️ Error receiving response.'
|
'⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep streaming until the typewriter catches up', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
|
|
||||||
mockStream$.next('Hi there')
|
|
||||||
mockStream$.complete()
|
|
||||||
expect(component.messages()[1].isStreaming).toBe(true)
|
|
||||||
expect(component.loading()).toBe(true)
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(1000)
|
|
||||||
expect(component.messages()[1].content).toBe('Hi there')
|
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show a slow response message while waiting', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
fixture.detectChanges()
|
|
||||||
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
|
||||||
expect(messagesEl.textContent).not.toContain('Still thinking...')
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(8000)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(messagesEl.textContent).toContain('Still thinking...')
|
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
|
||||||
expect(component.slowResponse()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should stop streaming and keep the partial response', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
fixture.detectChanges()
|
|
||||||
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
|
||||||
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
|
|
||||||
|
|
||||||
mockStream$.next('Hi there')
|
|
||||||
jest.advanceTimersByTime(20)
|
|
||||||
stopButton.click()
|
|
||||||
|
|
||||||
expect(mockStream$.observed).toBe(false)
|
|
||||||
expect(component.messages()[1].content).toBe('Hi there')
|
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(1000)
|
|
||||||
expect(component.messages()[1].content).toBe('Hi there')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should drop the empty response when stopped before any content', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
component.stopStreaming()
|
|
||||||
|
|
||||||
expect(component.messages()).toHaveLength(1)
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
component.stopStreaming() // no-op once stopped
|
|
||||||
expect(component.messages()).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should clear the conversation', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
mockStream$.next('Hi there')
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
|
|
||||||
expect(component.messages()).toHaveLength(0)
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
expect(mockStream$.observed).toBe(false)
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
jest.advanceTimersByTime(10)
|
|
||||||
expect(document.activeElement).toBe(
|
|
||||||
fixture.nativeElement.querySelector('#chatInput')
|
|
||||||
)
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
|
||||||
'Answers come from your documents'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
@@ -304,33 +207,4 @@ describe('ChatComponent', () => {
|
|||||||
component.searchInputKeyDown(event)
|
component.searchInputKeyDown(event)
|
||||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render markdown for assistant messages only', () => {
|
|
||||||
component.messages.set([
|
|
||||||
{ role: 'user', content: '**user**' },
|
|
||||||
{ role: 'assistant', content: '**assistant** <script>x</script>' },
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
const el: HTMLElement = fixture.nativeElement
|
|
||||||
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
|
|
||||||
'assistant'
|
|
||||||
)
|
|
||||||
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
|
||||||
expect(el.textContent).toContain('**user**')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should keep the destination hostname visible after sanitizing chat links', () => {
|
|
||||||
component.messages.set([
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content:
|
|
||||||
'[View original document](https://otherdomain.example/?data=secret)',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
const el: HTMLElement = fixture.nativeElement
|
|
||||||
const link = el.querySelector('.chat-markdown a')
|
|
||||||
expect(link?.textContent).toBe('View original document')
|
|
||||||
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
@@ -10,16 +10,12 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { filter, map, Subscription } from 'rxjs'
|
import { filter, map } from 'rxjs'
|
||||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
|
||||||
|
|
||||||
const SLOW_RESPONSE_DELAY_MS = 8000
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-chat',
|
selector: 'pngx-chat',
|
||||||
@@ -29,7 +25,6 @@ const SLOW_RESPONSE_DELAY_MS = 8000
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
MarkdownPipe,
|
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
@@ -39,8 +34,6 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly loading = signal(false)
|
readonly loading = signal(false)
|
||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
readonly slowResponse = signal(false)
|
|
||||||
public popperOptions = pngxPopperOptions
|
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -50,11 +43,6 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private typewriterBuffer: string[] = []
|
private typewriterBuffer: string[] = []
|
||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
private typewriterTimeout: ReturnType<typeof setTimeout>
|
|
||||||
private streamSubscription: Subscription
|
|
||||||
private streamingMessage: ChatMessage
|
|
||||||
private streamFinished = false
|
|
||||||
private slowResponseTimeout: ReturnType<typeof setTimeout>
|
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId()
|
return this.documentId()
|
||||||
@@ -93,96 +81,47 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.messages.update((messages) => [...messages, assistantMessage])
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.streamingMessage = assistantMessage
|
|
||||||
this.streamFinished = false
|
|
||||||
this.slowResponseTimeout = setTimeout(
|
|
||||||
() => this.slowResponse.set(true),
|
|
||||||
SLOW_RESPONSE_DELAY_MS
|
|
||||||
)
|
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.streamSubscription = this.chatService
|
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||||
.streamChat(this.documentId(), this.input())
|
next: (chunk) => {
|
||||||
.subscribe({
|
const nextResponse = parseChatResponse(chunk)
|
||||||
next: (chunk) => {
|
|
||||||
this.clearSlowResponse()
|
|
||||||
const nextResponse = parseChatResponse(chunk)
|
|
||||||
|
|
||||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
} else {
|
} else {
|
||||||
const visibleDelta = nextResponse.content.substring(
|
const visibleDelta = nextResponse.content.substring(
|
||||||
lastVisibleContent.length
|
lastVisibleContent.length
|
||||||
)
|
)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.references = nextResponse.references
|
assistantMessage.references = nextResponse.references
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.flushTypewriter(assistantMessage)
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
assistantMessage.isStreaming = false
|
||||||
this.finishStreaming(assistantMessage)
|
this.notifyMessagesChanged()
|
||||||
},
|
this.loading.set(false)
|
||||||
complete: () => {
|
},
|
||||||
// let the typewriter catch up before hiding the cursor
|
complete: () => {
|
||||||
this.streamFinished = true
|
assistantMessage.isStreaming = false
|
||||||
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
this.notifyMessagesChanged()
|
||||||
},
|
this.loading.set(false)
|
||||||
})
|
this.scrollToBottom()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
this.input.set('')
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
stopStreaming(): void {
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
const message = this.streamingMessage
|
|
||||||
if (!message?.isStreaming) return
|
|
||||||
|
|
||||||
this.streamSubscription?.unsubscribe()
|
|
||||||
this.flushTypewriter(message)
|
|
||||||
if (!message.content) {
|
|
||||||
this.messages.update((messages) => messages.filter((m) => m !== message))
|
|
||||||
}
|
|
||||||
this.finishStreaming(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearChat(): void {
|
|
||||||
this.stopStreaming()
|
|
||||||
this.messages.set([])
|
|
||||||
setTimeout(() => this.chatInput?.nativeElement.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
private finishStreaming(message: ChatMessage): void {
|
|
||||||
this.clearSlowResponse()
|
|
||||||
this.streamFinished = false
|
|
||||||
message.isStreaming = false
|
|
||||||
this.notifyMessagesChanged()
|
|
||||||
this.loading.set(false)
|
|
||||||
this.scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearSlowResponse(): void {
|
|
||||||
clearTimeout(this.slowResponseTimeout)
|
|
||||||
this.slowResponse.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushTypewriter(message: ChatMessage): void {
|
|
||||||
message.content += this.typewriterBuffer.join('')
|
|
||||||
this.cancelTypewriter()
|
|
||||||
}
|
|
||||||
|
|
||||||
private cancelTypewriter(): void {
|
|
||||||
clearTimeout(this.typewriterTimeout)
|
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
}
|
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
|
||||||
this.cancelTypewriter()
|
|
||||||
message.content = content
|
message.content = content
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
@@ -202,7 +141,6 @@ export class ChatComponent implements OnInit {
|
|||||||
playTypewriter(message: ChatMessage): void {
|
playTypewriter(message: ChatMessage): void {
|
||||||
if (this.typewriterBuffer.length === 0) {
|
if (this.typewriterBuffer.length === 0) {
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
if (this.streamFinished) this.finishStreaming(message)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +149,7 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
// 10ms per character
|
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||||
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyMessagesChanged(): void {
|
private notifyMessagesChanged(): void {
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { MarkdownPipe } from './markdown.pipe'
|
|
||||||
|
|
||||||
describe('MarkdownPipe', () => {
|
|
||||||
const pipe = new MarkdownPipe()
|
|
||||||
|
|
||||||
it('should return empty string for empty input', () => {
|
|
||||||
expect(pipe.transform(null)).toEqual('')
|
|
||||||
expect(pipe.transform(undefined)).toEqual('')
|
|
||||||
expect(pipe.transform('')).toEqual('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render basic markdown', () => {
|
|
||||||
const html = pipe.transform(
|
|
||||||
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
|
|
||||||
)
|
|
||||||
expect(html).toContain('<strong>bold</strong>')
|
|
||||||
expect(html).toContain('<em>em</em>')
|
|
||||||
expect(html).toContain('<code>code</code>')
|
|
||||||
expect(html).toContain('<li>one</li>')
|
|
||||||
expect(html).toContain('<table>')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should escape raw html', () => {
|
|
||||||
const html = pipe.transform(
|
|
||||||
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
|
|
||||||
)
|
|
||||||
expect(html).not.toContain('<img')
|
|
||||||
expect(html).not.toContain('<script')
|
|
||||||
expect(html).not.toContain('<b>')
|
|
||||||
expect(html).toContain('<script>')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not render images', () => {
|
|
||||||
const html = pipe.transform(
|
|
||||||
''
|
|
||||||
)
|
|
||||||
expect(html).not.toContain('<img')
|
|
||||||
expect(html).not.toContain('otherdomain.example')
|
|
||||||
expect(html).toContain('secret')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render safe links with target and rel', () => {
|
|
||||||
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
|
||||||
expect(html).toContain(
|
|
||||||
'<a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.paperless-ngx.com" title="Docs">docs</a>'
|
|
||||||
)
|
|
||||||
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
|
|
||||||
'href="mailto:a@b.c"'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should drop unsafe or relative links but keep their text', () => {
|
|
||||||
for (const href of [
|
|
||||||
'javascript:alert(1)',
|
|
||||||
'JaVaScRiPt:alert(1)',
|
|
||||||
'data:text/html,<script>alert(1)</script>',
|
|
||||||
'vbscript:msgbox',
|
|
||||||
'/api/documents/',
|
|
||||||
]) {
|
|
||||||
const html = pipe.transform(`[click](${href})`)
|
|
||||||
expect(html).not.toContain('<a')
|
|
||||||
expect(html).toContain('click')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show the actual hostname beside web links with misleading labels', () => {
|
|
||||||
for (const protocol of ['http', 'https']) {
|
|
||||||
const html = pipe.transform(
|
|
||||||
`[docs.paperless-ngx.com](${protocol}://docs.paperless-ngx.com@otherdomain.example/path?secret=1)`
|
|
||||||
)
|
|
||||||
expect(html).toContain(
|
|
||||||
'</a> <span class="text-muted small">(otherdomain.example)</span>'
|
|
||||||
)
|
|
||||||
expect(html).not.toContain('(docs.paperless-ngx.com)')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should omit the hostname label for mailto links', () => {
|
|
||||||
expect(pipe.transform('[mail](mailto:a@b.c)')).toBe(
|
|
||||||
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="mailto:a@b.c">mail</a></p>\n'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should escape link titles', () => {
|
|
||||||
const html = pipe.transform(
|
|
||||||
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
|
||||||
)
|
|
||||||
expect(html).not.toMatch(/"\s*onmouseover=/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core'
|
|
||||||
import { Marked, Renderer, Tokens } from 'marked'
|
|
||||||
|
|
||||||
const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
|
|
||||||
|
|
||||||
function allowedUrl(href: string): URL | null {
|
|
||||||
try {
|
|
||||||
const url = new URL(href)
|
|
||||||
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treat chat content as untrusted: no raw HTML, no images, and only
|
|
||||||
// absolute links. All escaping is done by marked's default renderer, and
|
|
||||||
// Angular's sanitizer runs on top of this as well.
|
|
||||||
const renderer: Partial<Renderer> = {
|
|
||||||
html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
|
|
||||||
return this.text({ type: 'text', raw, text })
|
|
||||||
},
|
|
||||||
image(this: Renderer, { raw, text }: Tokens.Image): string {
|
|
||||||
return this.text({ type: 'text', raw, text })
|
|
||||||
},
|
|
||||||
link(this: Renderer, token: Tokens.Link): string {
|
|
||||||
const url = allowedUrl(token.href)
|
|
||||||
if (!url) return this.parser.parseInline(token.tokens)
|
|
||||||
const anchor = Renderer.prototype.link
|
|
||||||
.call(this, token)
|
|
||||||
.replace('<a ', '<a target="_blank" rel="noopener noreferrer nofollow" ')
|
|
||||||
if (!url.hostname) return anchor
|
|
||||||
const hostname = this.text({ type: 'text', raw: '', text: url.hostname })
|
|
||||||
return `${anchor} <span class="text-muted small">(${hostname})</span>`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const markdown = new Marked({
|
|
||||||
async: false,
|
|
||||||
gfm: true,
|
|
||||||
breaks: true,
|
|
||||||
renderer,
|
|
||||||
})
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'markdown',
|
|
||||||
})
|
|
||||||
export class MarkdownPipe implements PipeTransform {
|
|
||||||
transform(value: string | null | undefined): string {
|
|
||||||
if (!value) return ''
|
|
||||||
return markdown.parse(value) as string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
arrowDownUp,
|
arrowDownUp,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
arrowRepeat,
|
arrowRepeat,
|
||||||
arrowReturnLeft,
|
|
||||||
arrowRight,
|
arrowRight,
|
||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
@@ -284,7 +283,6 @@ const icons = {
|
|||||||
arrowDownUp,
|
arrowDownUp,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
arrowRepeat,
|
arrowRepeat,
|
||||||
arrowReturnLeft,
|
|
||||||
arrowRight,
|
arrowRight,
|
||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
|
|||||||
+184
-219
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -23,6 +24,7 @@ from django.db.models import When
|
|||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from documents import pdf_ops
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
@@ -123,6 +125,11 @@ def _resolve_root_and_source_doc(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scratch_path(name: str) -> Path:
|
||||||
|
"""A path inside a fresh directory under SCRATCH_DIR."""
|
||||||
|
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
|
||||||
|
|
||||||
|
|
||||||
def set_correspondent(
|
def set_correspondent(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
correspondent: Correspondent,
|
correspondent: Correspondent,
|
||||||
@@ -499,8 +506,6 @@ def rotate(
|
|||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
docs_by_root_id.setdefault(pair.root_doc.id, pair)
|
docs_by_root_id.setdefault(pair.root_doc.id, pair)
|
||||||
|
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
for pair in docs_by_root_id.values():
|
for pair in docs_by_root_id.values():
|
||||||
if pair.source_doc.mime_type != "application/pdf":
|
if pair.source_doc.mime_type != "application/pdf":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -513,11 +518,7 @@ def rotate(
|
|||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
/ f"{pair.root_doc.id}_rotated.pdf"
|
/ f"{pair.root_doc.id}_rotated.pdf"
|
||||||
)
|
)
|
||||||
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
pdf_ops.rotate_pdf(pair.source_doc.source_path, filepath, degrees)
|
||||||
for page in pdf.pages:
|
|
||||||
page.rotate(degrees, relative=True)
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(filepath)
|
|
||||||
|
|
||||||
# Preserve metadata/permissions via overrides; mark as new version
|
# Preserve metadata/permissions via overrides; mark as new version
|
||||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
@@ -560,48 +561,45 @@ def merge(
|
|||||||
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
||||||
docs_by_id = {doc.id: doc for doc in qs}
|
docs_by_id = {doc.id: doc for doc in qs}
|
||||||
affected_docs: list[int] = []
|
affected_docs: list[int] = []
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
merged_pdf = pikepdf.new()
|
|
||||||
version: str = merged_pdf.pdf_version
|
|
||||||
handoff_asn: int | None = None
|
handoff_asn: int | None = None
|
||||||
# use doc_ids to preserve order
|
with pdf_ops.PdfMerger() as merger:
|
||||||
for doc_id in doc_ids:
|
# use doc_ids to preserve order
|
||||||
doc = docs_by_id.get(doc_id)
|
for doc_id in doc_ids:
|
||||||
if doc is None:
|
doc = docs_by_id.get(doc_id)
|
||||||
continue
|
if doc is None:
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
continue
|
||||||
try:
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
doc_path = (
|
try:
|
||||||
pair.source_doc.archive_path
|
# archive_path is None when there is no archive version
|
||||||
if archive_fallback
|
archive_path = (
|
||||||
and pair.source_doc.mime_type != "application/pdf"
|
pair.source_doc.archive_path
|
||||||
and pair.source_doc.has_archive_version
|
if archive_fallback
|
||||||
else pair.source_doc.source_path
|
and pair.source_doc.mime_type != "application/pdf"
|
||||||
)
|
else None
|
||||||
with pikepdf.open(str(doc_path)) as pdf:
|
)
|
||||||
version = max(version, pdf.pdf_version)
|
merger.add(
|
||||||
merged_pdf.pages.extend(pdf.pages)
|
archive_path
|
||||||
affected_docs.append(doc.id)
|
if archive_path is not None
|
||||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
else pair.source_doc.source_path,
|
||||||
handoff_asn = doc.archive_serial_number
|
)
|
||||||
except Exception as e:
|
affected_docs.append(doc.id)
|
||||||
logger.exception(
|
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
handoff_asn = doc.archive_serial_number
|
||||||
)
|
except Exception as e:
|
||||||
if len(affected_docs) == 0:
|
logger.exception(
|
||||||
logger.warning("No documents were merged")
|
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||||
return "OK"
|
)
|
||||||
|
if len(affected_docs) == 0:
|
||||||
|
logger.warning("No documents were merged")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
filepath = (
|
filepath = (
|
||||||
Path(
|
Path(
|
||||||
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
||||||
|
)
|
||||||
|
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
|
||||||
)
|
)
|
||||||
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
|
merger.save(filepath)
|
||||||
)
|
|
||||||
merged_pdf.remove_unreferenced_resources()
|
|
||||||
merged_pdf.save(filepath, min_version=version)
|
|
||||||
merged_pdf.close()
|
|
||||||
|
|
||||||
if metadata_document_id:
|
if metadata_document_id:
|
||||||
metadata_document = qs.get(id=metadata_document_id)
|
metadata_document = qs.get(id=metadata_document_id)
|
||||||
@@ -777,64 +775,60 @@ def split(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
outputs = [
|
||||||
for idx, split_doc in enumerate(pages):
|
(
|
||||||
dst: pikepdf.Pdf = pikepdf.new()
|
[pdf_ops.PageSpec(page) for page in split_doc],
|
||||||
for page in split_doc:
|
partial(_scratch_path, f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"),
|
||||||
dst.pages.append(pdf.pages[page - 1])
|
)
|
||||||
filepath: Path = (
|
for split_doc in pages
|
||||||
Path(
|
]
|
||||||
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
filepaths = pdf_ops.build_pdfs(pair.source_doc.source_path, outputs)
|
||||||
)
|
|
||||||
/ f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"
|
|
||||||
)
|
|
||||||
dst.remove_unreferenced_resources()
|
|
||||||
dst.save(filepath)
|
|
||||||
dst.close()
|
|
||||||
|
|
||||||
overrides: DocumentMetadataOverrides = (
|
for idx, (split_doc, filepath) in enumerate(
|
||||||
DocumentMetadataOverrides().from_document(doc)
|
zip(pages, filepaths, strict=True),
|
||||||
)
|
):
|
||||||
overrides.title = f"{doc.title} (split {idx + 1})"
|
overrides: DocumentMetadataOverrides = (
|
||||||
if user is not None:
|
DocumentMetadataOverrides().from_document(doc)
|
||||||
overrides.owner_id = user.id
|
)
|
||||||
if not delete_originals:
|
overrides.title = f"{doc.title} (split {idx + 1})"
|
||||||
overrides.skip_asn_if_exists = True
|
if user is not None:
|
||||||
logger.info(
|
overrides.owner_id = user.id
|
||||||
f"Adding split document with pages {split_doc} to the task queue.",
|
if not delete_originals:
|
||||||
)
|
overrides.skip_asn_if_exists = True
|
||||||
consume_tasks.append(
|
logger.info(
|
||||||
consume_file.s(
|
f"Adding split document with pages {split_doc} to the task queue.",
|
||||||
input_doc=ConsumableDocument(
|
)
|
||||||
source=DocumentSource.ConsumeFolder,
|
consume_tasks.append(
|
||||||
original_file=filepath,
|
consume_file.s(
|
||||||
),
|
input_doc=ConsumableDocument(
|
||||||
overrides=overrides,
|
source=DocumentSource.ConsumeFolder,
|
||||||
).set(headers={"trigger_source": trigger_source}),
|
original_file=filepath,
|
||||||
)
|
),
|
||||||
|
overrides=overrides,
|
||||||
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
|
)
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
backup = release_archive_serial_numbers([doc.id])
|
backup = release_archive_serial_numbers([doc.id])
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original document after consumption of the split documents",
|
"Queueing removal of original document after consumption of the split documents",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
chord(
|
chord(
|
||||||
header=consume_tasks,
|
header=consume_tasks,
|
||||||
body=delete.si([doc.id]),
|
body=delete.si([doc.id]),
|
||||||
).on_error(
|
).on_error(
|
||||||
restore_archive_serial_numbers_task.s(backup),
|
restore_archive_serial_numbers_task.s(backup),
|
||||||
).apply_async()
|
).apply_async()
|
||||||
except Exception:
|
except Exception:
|
||||||
restore_archive_serial_numbers(backup)
|
restore_archive_serial_numbers(backup)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error splitting document {doc.id}: {e}")
|
logger.exception(f"Error splitting document {doc.id}: {e}")
|
||||||
@@ -855,8 +849,6 @@ def delete_pages(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
pages = sorted(pages) # sort pages to avoid index issues
|
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Produce edited PDF to a temp file and create a new version
|
# Produce edited PDF to a temp file and create a new version
|
||||||
@@ -864,13 +856,7 @@ def delete_pages(
|
|||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
/ f"{pair.root_doc.id}_pages_deleted.pdf"
|
/ f"{pair.root_doc.id}_pages_deleted.pdf"
|
||||||
)
|
)
|
||||||
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
pdf_ops.remove_pages(pair.source_doc.source_path, filepath, pages)
|
||||||
offset = 1 # pages are 1-indexed
|
|
||||||
for page_num in pages:
|
|
||||||
pdf.pages.remove(pdf.pages[page_num - offset])
|
|
||||||
offset += 1 # remove() changes the index of the pages
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(filepath)
|
|
||||||
|
|
||||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
@@ -919,47 +905,28 @@ def edit_pdf(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
pdf_docs: list[pikepdf.Pdf] = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not operations:
|
output_count = pdf_ops.validate_page_operations(
|
||||||
raise ValueError("Output document index is out of bounds")
|
operations,
|
||||||
|
single_output=update_document,
|
||||||
max_idx = max(op.get("doc", 0) for op in operations)
|
)
|
||||||
if update_document and max_idx > 0:
|
page_specs: list[list[pdf_ops.PageSpec]] = [[] for _ in range(output_count)]
|
||||||
logger.error(
|
for op in operations:
|
||||||
"Update requested but multiple output documents specified",
|
page_specs[op.get("doc", 0)].append(
|
||||||
|
pdf_ops.PageSpec(op["page"], op.get("rotate", 0)),
|
||||||
)
|
)
|
||||||
raise ValueError("Multiple output documents specified")
|
|
||||||
|
|
||||||
if any(
|
|
||||||
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
|
|
||||||
for op in operations
|
|
||||||
):
|
|
||||||
raise ValueError("Output document index is out of bounds")
|
|
||||||
|
|
||||||
with pikepdf.open(pair.source_doc.source_path) as src:
|
|
||||||
# prepare output documents
|
|
||||||
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
|
||||||
|
|
||||||
for op in operations:
|
|
||||||
dst = pdf_docs[op.get("doc", 0)]
|
|
||||||
page = src.pages[op["page"] - 1]
|
|
||||||
dst.pages.append(page)
|
|
||||||
if op.get("rotate"):
|
|
||||||
dst.pages[-1].rotate(op["rotate"], relative=True)
|
|
||||||
|
|
||||||
if update_document:
|
if update_document:
|
||||||
# Create a new version from the edited PDF rather than replacing in-place
|
# Create a new version from the edited PDF rather than replacing in-place
|
||||||
pdf = pdf_docs[0]
|
(filepath,) = pdf_ops.build_pdfs(
|
||||||
pdf.remove_unreferenced_resources()
|
pair.source_doc.source_path,
|
||||||
filepath: Path = (
|
[
|
||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
(
|
||||||
/ f"{pair.root_doc.id}_edited.pdf"
|
page_specs[0],
|
||||||
|
partial(_scratch_path, f"{pair.root_doc.id}_edited.pdf"),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
pdf.save(filepath)
|
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if include_metadata
|
if include_metadata
|
||||||
@@ -980,6 +947,19 @@ def edit_pdf(
|
|||||||
headers={"trigger_source": trigger_source},
|
headers={"trigger_source": trigger_source},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
version_filepaths = pdf_ops.build_pdfs(
|
||||||
|
pair.source_doc.source_path,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
specs,
|
||||||
|
partial(
|
||||||
|
_scratch_path,
|
||||||
|
f"{pair.root_doc.id}_edit_{idx}.pdf",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for idx, specs in enumerate(page_specs, start=1)
|
||||||
|
],
|
||||||
|
)
|
||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
@@ -991,15 +971,9 @@ def edit_pdf(
|
|||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
if not delete_original:
|
if not delete_original:
|
||||||
overrides.skip_asn_if_exists = True
|
overrides.skip_asn_if_exists = True
|
||||||
if delete_original and len(pdf_docs) == 1:
|
if delete_original and output_count == 1:
|
||||||
overrides.asn = pair.root_doc.archive_serial_number
|
overrides.asn = pair.root_doc.archive_serial_number
|
||||||
for idx, pdf in enumerate(pdf_docs, start=1):
|
for version_filepath in version_filepaths:
|
||||||
version_filepath: Path = (
|
|
||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
|
||||||
/ f"{pair.root_doc.id}_edit_{idx}.pdf"
|
|
||||||
)
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(version_filepath)
|
|
||||||
consume_tasks.append(
|
consume_tasks.append(
|
||||||
consume_file.s(
|
consume_file.s(
|
||||||
input_doc=ConsumableDocument(
|
input_doc=ConsumableDocument(
|
||||||
@@ -1049,8 +1023,6 @@ def remove_password(
|
|||||||
"""
|
"""
|
||||||
Remove password protection from PDF documents.
|
Remove password protection from PDF documents.
|
||||||
"""
|
"""
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
for doc_id in doc_ids:
|
for doc_id in doc_ids:
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
@@ -1064,76 +1036,69 @@ def remove_password(
|
|||||||
doc.id,
|
doc.id,
|
||||||
pair.source_doc.source_path,
|
pair.source_doc.source_path,
|
||||||
)
|
)
|
||||||
try:
|
if not pdf_ops.needs_decrypt(source_path):
|
||||||
with pikepdf.open(source_path) as pdf:
|
logger.info(
|
||||||
if not pdf.is_encrypted:
|
"Skipping password removal for document %s because the "
|
||||||
logger.info(
|
"source PDF is not encrypted",
|
||||||
"Skipping password removal for document %s because the "
|
pair.root_doc.id,
|
||||||
"source PDF is not encrypted",
|
|
||||||
pair.root_doc.id,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
except pikepdf.PasswordError:
|
|
||||||
# Password-protected PDFs need the supplied password below.
|
|
||||||
pass
|
|
||||||
|
|
||||||
with pikepdf.open(source_path, password=password) as pdf:
|
|
||||||
filepath: Path = (
|
|
||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
|
||||||
/ f"{pair.root_doc.id}_unprotected.pdf"
|
|
||||||
)
|
)
|
||||||
pdf.remove_unreferenced_resources()
|
continue
|
||||||
pdf.save(filepath)
|
|
||||||
|
|
||||||
if update_document:
|
filepath = pdf_ops.decrypt_pdf(
|
||||||
# Create a new version rather than modifying the root/original in place.
|
source_path,
|
||||||
overrides = (
|
partial(_scratch_path, f"{pair.root_doc.id}_unprotected.pdf"),
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
password,
|
||||||
if include_metadata
|
)
|
||||||
else DocumentMetadataOverrides()
|
|
||||||
)
|
if update_document:
|
||||||
if user is not None:
|
# Create a new version rather than modifying the root/original in place.
|
||||||
overrides.owner_id = user.id
|
overrides = (
|
||||||
overrides.actor_id = user.id
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
consume_file.apply_async(
|
if include_metadata
|
||||||
kwargs={
|
else DocumentMetadataOverrides()
|
||||||
"input_doc": ConsumableDocument(
|
)
|
||||||
source=DocumentSource.ConsumeFolder,
|
if user is not None:
|
||||||
original_file=filepath,
|
overrides.owner_id = user.id
|
||||||
root_document_id=pair.root_doc.id,
|
overrides.actor_id = user.id
|
||||||
),
|
consume_file.apply_async(
|
||||||
"overrides": overrides,
|
kwargs={
|
||||||
},
|
"input_doc": ConsumableDocument(
|
||||||
headers={"trigger_source": trigger_source},
|
source=DocumentSource.ConsumeFolder,
|
||||||
)
|
original_file=filepath,
|
||||||
|
root_document_id=pair.root_doc.id,
|
||||||
|
),
|
||||||
|
"overrides": overrides,
|
||||||
|
},
|
||||||
|
headers={"trigger_source": trigger_source},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
consume_tasks = []
|
||||||
|
overrides = (
|
||||||
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
|
if include_metadata
|
||||||
|
else DocumentMetadataOverrides()
|
||||||
|
)
|
||||||
|
if user is not None:
|
||||||
|
overrides.owner_id = user.id
|
||||||
|
overrides.actor_id = user.id
|
||||||
|
|
||||||
|
consume_tasks.append(
|
||||||
|
consume_file.s(
|
||||||
|
input_doc=ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=filepath,
|
||||||
|
),
|
||||||
|
overrides=overrides,
|
||||||
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if delete_original:
|
||||||
|
chord(
|
||||||
|
header=consume_tasks,
|
||||||
|
body=delete.si([doc.id]),
|
||||||
|
).delay()
|
||||||
else:
|
else:
|
||||||
consume_tasks = []
|
group(consume_tasks).delay()
|
||||||
overrides = (
|
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
|
||||||
if include_metadata
|
|
||||||
else DocumentMetadataOverrides()
|
|
||||||
)
|
|
||||||
if user is not None:
|
|
||||||
overrides.owner_id = user.id
|
|
||||||
overrides.actor_id = user.id
|
|
||||||
|
|
||||||
consume_tasks.append(
|
|
||||||
consume_file.s(
|
|
||||||
input_doc=ConsumableDocument(
|
|
||||||
source=DocumentSource.ConsumeFolder,
|
|
||||||
original_file=filepath,
|
|
||||||
),
|
|
||||||
overrides=overrides,
|
|
||||||
).set(headers={"trigger_source": trigger_source}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if delete_original:
|
|
||||||
chord(
|
|
||||||
header=consume_tasks,
|
|
||||||
body=delete.si([doc.id]),
|
|
||||||
).delay()
|
|
||||||
else:
|
|
||||||
group(consume_tasks).delay()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
Pure PDF page operations used by documents.bulk_edit.
|
||||||
|
|
||||||
|
This module deliberately knows nothing about Django, Celery or the documents
|
||||||
|
app: callers resolve documents, choose output paths and queue work. Every
|
||||||
|
function that writes a PDF removes unreferenced resources before saving.
|
||||||
|
|
||||||
|
pikepdf is always called as ``pikepdf.open(...)`` / ``pikepdf.new()`` (never
|
||||||
|
``from pikepdf import open``) so tests can patch those module attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from types import TracebackType
|
||||||
|
|
||||||
|
|
||||||
|
class PageSpec(NamedTuple):
|
||||||
|
"""One page of an output PDF: a 1-indexed source page, optionally rotated."""
|
||||||
|
|
||||||
|
page: int
|
||||||
|
rotate: int = 0 # relative degrees, 0 leaves the page alone
|
||||||
|
|
||||||
|
|
||||||
|
def _require_positive(pages: Iterable[int]) -> None:
|
||||||
|
for page in pages:
|
||||||
|
if page < 1:
|
||||||
|
raise ValueError(f"Page numbers start at 1, got {page}")
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_pdf(src: Path, dst: Path, degrees: int) -> None:
|
||||||
|
"""
|
||||||
|
Rotate every page relatively on the opened document, not a rebuild, so Info,
|
||||||
|
XMP and outlines are kept. ``src`` is not modified.
|
||||||
|
"""
|
||||||
|
with pikepdf.open(src) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
page.rotate(degrees, relative=True)
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(dst)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_pages(src: Path, dst: Path, pages: Iterable[int]) -> None:
|
||||||
|
"""
|
||||||
|
Remove 1-indexed pages from the opened document, not a rebuild, so Info, XMP
|
||||||
|
and outlines are kept. ``src`` is not modified.
|
||||||
|
|
||||||
|
Duplicates are ignored. Pages are removed highest first so earlier removals
|
||||||
|
never shift the index of later ones.
|
||||||
|
"""
|
||||||
|
unique = sorted(set(pages))
|
||||||
|
_require_positive(unique)
|
||||||
|
with pikepdf.open(src) as pdf:
|
||||||
|
for page_num in reversed(unique):
|
||||||
|
del pdf.pages[page_num - 1]
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(dst)
|
||||||
|
|
||||||
|
|
||||||
|
def build_pdfs(
|
||||||
|
src: Path,
|
||||||
|
outputs: Sequence[tuple[Sequence[PageSpec], Callable[[], Path]]],
|
||||||
|
) -> list[Path]:
|
||||||
|
"""
|
||||||
|
Build one new PDF per output from pages of ``src``, opening ``src`` once.
|
||||||
|
|
||||||
|
Each output is ``(page_specs, make_dst)``. Every page number is checked against
|
||||||
|
``src`` before any output is built, and ``make_dst`` is called after that
|
||||||
|
output's pages are copied and immediately before it is saved, so a bad page
|
||||||
|
number in any output never leaves a destination behind. Document-level data
|
||||||
|
(Info, XMP, outlines) is not carried over. Returns the written paths in output
|
||||||
|
order.
|
||||||
|
"""
|
||||||
|
for specs, _ in outputs:
|
||||||
|
_require_positive(spec.page for spec in specs)
|
||||||
|
|
||||||
|
written: list[Path] = []
|
||||||
|
with pikepdf.open(src) as source:
|
||||||
|
page_count = len(source.pages)
|
||||||
|
for specs, _ in outputs:
|
||||||
|
for spec in specs:
|
||||||
|
if spec.page > page_count:
|
||||||
|
raise IndexError(
|
||||||
|
f"Page {spec.page} is out of range, the PDF has "
|
||||||
|
f"{page_count} pages",
|
||||||
|
)
|
||||||
|
for specs, make_dst in outputs:
|
||||||
|
dst = pikepdf.new()
|
||||||
|
for spec in specs:
|
||||||
|
dst.pages.append(source.pages[spec.page - 1])
|
||||||
|
if spec.rotate:
|
||||||
|
dst.pages[-1].rotate(spec.rotate, relative=True)
|
||||||
|
dst.remove_unreferenced_resources()
|
||||||
|
path = make_dst()
|
||||||
|
dst.save(path)
|
||||||
|
dst.close()
|
||||||
|
written.append(path)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def validate_page_operations(
|
||||||
|
operations: Sequence[Mapping[str, int]],
|
||||||
|
*,
|
||||||
|
single_output: bool,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Validate ``edit_pdf`` style operations and return the output document count.
|
||||||
|
|
||||||
|
Each operation has ``page`` and optionally ``rotate`` and ``doc`` (the output
|
||||||
|
document index, default 0). The bounds rule is kept as it was: a ``doc`` index
|
||||||
|
must be below the number of operations.
|
||||||
|
"""
|
||||||
|
if not operations:
|
||||||
|
raise ValueError("Output document index is out of bounds")
|
||||||
|
|
||||||
|
max_idx = max(op.get("doc", 0) for op in operations)
|
||||||
|
if single_output and max_idx > 0:
|
||||||
|
raise ValueError("Multiple output documents specified")
|
||||||
|
|
||||||
|
if any(
|
||||||
|
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations) for op in operations
|
||||||
|
):
|
||||||
|
raise ValueError("Output document index is out of bounds")
|
||||||
|
|
||||||
|
return max_idx + 1
|
||||||
|
|
||||||
|
|
||||||
|
def needs_decrypt(src: Path) -> bool:
|
||||||
|
"""
|
||||||
|
True if ``src`` is encrypted. A PDF that needs a password to open at all
|
||||||
|
counts as encrypted.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with pikepdf.open(src) as pdf:
|
||||||
|
return bool(pdf.is_encrypted)
|
||||||
|
except pikepdf.PasswordError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_pdf(src: Path, make_dst: Callable[[], Path], password: str) -> Path:
|
||||||
|
"""
|
||||||
|
Write an unencrypted copy of ``src`` and return its path.
|
||||||
|
|
||||||
|
``make_dst`` is only called once the password has been accepted, so a wrong
|
||||||
|
password never leaves a destination behind.
|
||||||
|
"""
|
||||||
|
with pikepdf.open(src, password=password) as pdf:
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
dst = make_dst()
|
||||||
|
pdf.save(dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
class PdfMerger:
|
||||||
|
"""
|
||||||
|
Accumulates the pages of several PDFs into one new PDF.
|
||||||
|
|
||||||
|
``add`` raises if a source cannot be read; deciding whether to skip it is the
|
||||||
|
caller's policy. Use as a context manager so the merged PDF is closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._pdf = pikepdf.new()
|
||||||
|
self._version: str = self._pdf.pdf_version
|
||||||
|
|
||||||
|
def __enter__(self) -> PdfMerger:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
tb: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
self._pdf.close()
|
||||||
|
|
||||||
|
def add(self, path: Path) -> None:
|
||||||
|
with pikepdf.open(str(path)) as pdf:
|
||||||
|
self._version = max(self._version, pdf.pdf_version)
|
||||||
|
self._pdf.pages.extend(pdf.pages)
|
||||||
|
|
||||||
|
def save(self, dst: Path) -> None:
|
||||||
|
self._pdf.remove_unreferenced_resources()
|
||||||
|
self._pdf.save(dst, min_version=self._version)
|
||||||
@@ -2145,6 +2145,8 @@ class BulkEditSerializer(
|
|||||||
raise serializers.ValidationError("pages must be a list")
|
raise serializers.ValidationError("pages must be a list")
|
||||||
if not all(isinstance(i, int) for i in parameters["pages"]):
|
if not all(isinstance(i, int) for i in parameters["pages"]):
|
||||||
raise serializers.ValidationError("pages must be a list of integers")
|
raise serializers.ValidationError("pages must be a list of integers")
|
||||||
|
if any(i < 1 for i in parameters["pages"]):
|
||||||
|
raise serializers.ValidationError("pages must be positive integers")
|
||||||
|
|
||||||
def _validate_parameters_merge(self, parameters) -> None:
|
def _validate_parameters_merge(self, parameters) -> None:
|
||||||
if "delete_originals" in parameters:
|
if "delete_originals" in parameters:
|
||||||
|
|||||||
@@ -1843,6 +1843,36 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
||||||
|
def test_bulk_edit_delete_pages_rejects_pages_below_one(self, m) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A legacy delete_pages bulk edit
|
||||||
|
WHEN:
|
||||||
|
- API to bulk edit is called with a page number below 1
|
||||||
|
THEN:
|
||||||
|
- API returns HTTP 400
|
||||||
|
- delete_pages is not called
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "delete_pages")
|
||||||
|
|
||||||
|
for pages in ([0], [-1], [1, 0]):
|
||||||
|
with self.subTest(pages=pages):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "delete_pages",
|
||||||
|
"parameters": {"pages": pages},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"pages must be positive integers", response.content)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.rotate")
|
@mock.patch("documents.views.bulk_edit.rotate")
|
||||||
def test_rotate_insufficient_permissions(self, m) -> None:
|
def test_rotate_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import shutil
|
import shutil
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pikepdf
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -23,6 +23,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.pdf_ops import PageSpec
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
@@ -796,16 +797,14 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.img_doc.save()
|
self.img_doc.save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mock_password_required_pdf(
|
def fake_decrypt(
|
||||||
mock_open: mock.Mock,
|
src: Path,
|
||||||
fake_pdf: mock.Mock,
|
make_dst: Callable[[], Path],
|
||||||
) -> None:
|
password: str,
|
||||||
password_context = mock.MagicMock()
|
) -> Path:
|
||||||
password_context.__enter__.return_value = fake_pdf
|
dst = make_dst()
|
||||||
mock_open.side_effect = [
|
dst.write_bytes(b"password removed")
|
||||||
pikepdf.PasswordError("password required"),
|
return dst
|
||||||
password_context,
|
|
||||||
]
|
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_merge(self, mock_consume_file) -> None:
|
def test_merge(self, mock_consume_file) -> None:
|
||||||
@@ -850,12 +849,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.PdfMerger")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_merge_uses_latest_version_source_for_root_selection(
|
def test_merge_uses_latest_version_source_for_root_selection(
|
||||||
self,
|
self,
|
||||||
mock_consume_file,
|
mock_consume_file,
|
||||||
mock_open_pdf,
|
mock_merger,
|
||||||
) -> None:
|
) -> None:
|
||||||
version_file = self.dirs.scratch_dir / "sample2_version_merge.pdf"
|
version_file = self.dirs.scratch_dir / "sample2_version_merge.pdf"
|
||||||
shutil.copy(self.doc2.source_path, version_file)
|
shutil.copy(self.doc2.source_path, version_file)
|
||||||
@@ -866,16 +865,14 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
filename=version_file,
|
filename=version_file,
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
fake_pdf = mock.MagicMock()
|
merger = mock_merger.return_value.__enter__.return_value
|
||||||
fake_pdf.pdf_version = "1.7"
|
merger.save.side_effect = lambda dst: shutil.copy(version.source_path, dst)
|
||||||
fake_pdf.pages = [mock.Mock()]
|
|
||||||
mock_open_pdf.return_value.__enter__.return_value = fake_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.merge([self.doc2.id])
|
result = bulk_edit.merge([self.doc2.id])
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_open_pdf.assert_called_once_with(str(version.source_path))
|
merger.add.assert_called_once_with(version.source_path)
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete.si")
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@@ -1037,18 +1034,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.delay")
|
@mock.patch("documents.tasks.consume_file.delay")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.PdfMerger.add")
|
||||||
def test_merge_with_errors(self, mock_open_pdf, mock_consume_file) -> None:
|
def test_merge_with_errors(self, mock_add, mock_consume_file) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Merge action is called with 2 documents
|
- Merge action is called with 2 documents
|
||||||
- Error occurs when opening both files
|
- Error occurs when adding both files
|
||||||
THEN:
|
THEN:
|
||||||
- Consume file should not be called
|
- Consume file should not be called
|
||||||
"""
|
"""
|
||||||
mock_open_pdf.side_effect = Exception("Error opening PDF")
|
mock_add.side_effect = Exception("Error opening PDF")
|
||||||
doc_ids = [self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
@@ -1085,12 +1082,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.build_pdfs")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_split_uses_latest_version_source_for_root_selection(
|
def test_split_uses_latest_version_source_for_root_selection(
|
||||||
self,
|
self,
|
||||||
mock_consume_file,
|
mock_consume_file,
|
||||||
mock_open_pdf,
|
mock_build_pdfs,
|
||||||
mock_group,
|
mock_group,
|
||||||
) -> None:
|
) -> None:
|
||||||
version_file = self.dirs.scratch_dir / "sample2_version_split.pdf"
|
version_file = self.dirs.scratch_dir / "sample2_version_split.pdf"
|
||||||
@@ -1102,17 +1099,15 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
filename=version_file,
|
filename=version_file,
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
fake_pdf = mock.MagicMock()
|
mock_build_pdfs.return_value = [version.source_path, version.source_path]
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
|
||||||
mock_open_pdf.return_value.__enter__.return_value = fake_pdf
|
|
||||||
mock_group.return_value.delay.return_value = None
|
mock_group.return_value.delay.return_value = None
|
||||||
|
|
||||||
result = bulk_edit.split([self.doc2.id], [[1], [2]])
|
result = bulk_edit.split([self.doc2.id], [[1], [2]])
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_open_pdf.assert_called_once_with(version.source_path)
|
self.assertEqual(mock_build_pdfs.call_args.args[0], version.source_path)
|
||||||
mock_consume_file.assert_not_called()
|
self.assertEqual(mock_consume_file.call_count, 2)
|
||||||
mock_group.return_value.delay.assert_not_called()
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete.si")
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@@ -1200,18 +1195,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(self.doc2.archive_serial_number, 222)
|
self.assertEqual(self.doc2.archive_serial_number, 222)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("documents.pdf_ops.build_pdfs")
|
||||||
def test_split_with_errors(self, mock_save_pdf, mock_consume_file) -> None:
|
def test_split_with_errors(self, mock_build_pdfs, mock_consume_file) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Split action is called with 1 document and 2 page groups
|
- Split action is called with 1 document and 2 page groups
|
||||||
- Error occurs when saving the files
|
- Error occurs when building the files
|
||||||
THEN:
|
THEN:
|
||||||
- Consume file should not be called
|
- Consume file should not be called
|
||||||
"""
|
"""
|
||||||
mock_save_pdf.side_effect = Exception("Error saving PDF")
|
mock_build_pdfs.side_effect = Exception("Error building PDFs")
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [[1, 2], [3]]
|
pages = [[1, 2], [3]]
|
||||||
|
|
||||||
@@ -1246,10 +1241,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("documents.pdf_ops.rotate_pdf")
|
||||||
def test_rotate_with_error(
|
def test_rotate_with_error(
|
||||||
self,
|
self,
|
||||||
mock_pdf_save,
|
mock_rotate_pdf,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1257,11 +1252,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Rotate action is called with 2 documents
|
- Rotate action is called with 2 documents
|
||||||
- PikePDF raises an error
|
- Rotating the PDF raises an error
|
||||||
THEN:
|
THEN:
|
||||||
- Rotate action should be called 0 times
|
- Rotate action should be called 0 times
|
||||||
"""
|
"""
|
||||||
mock_pdf_save.side_effect = Exception("Error saving PDF")
|
mock_rotate_pdf.side_effect = Exception("Error rotating PDF")
|
||||||
doc_ids = [self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
@@ -1296,10 +1291,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.rotate_pdf")
|
||||||
def test_rotate_explicit_selection_uses_root_source_when_root_selected(
|
def test_rotate_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_open,
|
mock_rotate_pdf,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1308,9 +1303,6 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock()]
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.rotate(
|
result = bulk_edit.rotate(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1319,26 +1311,35 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_open.assert_called_once_with(self.doc2.source_path)
|
self.assertEqual(mock_rotate_pdf.call_args.args[0], self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("documents.pdf_ops.remove_pages")
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
def test_delete_pages(self, mock_magic, mock_pdf_save, mock_consume_delay) -> None:
|
def test_delete_pages(
|
||||||
|
self,
|
||||||
|
mock_magic,
|
||||||
|
mock_remove_pages,
|
||||||
|
mock_consume_delay,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Delete pages action is called with 1 document and 2 pages
|
- Delete pages action is called with 1 document and 2 pages
|
||||||
THEN:
|
THEN:
|
||||||
- Save should be called once
|
- The pages are removed from the document's source PDF
|
||||||
- A new version should be enqueued via consume_file
|
- A new version should be enqueued via consume_file
|
||||||
"""
|
"""
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [1, 3]
|
pages = [1, 3]
|
||||||
result = bulk_edit.delete_pages(doc_ids, pages)
|
result = bulk_edit.delete_pages(doc_ids, pages)
|
||||||
mock_pdf_save.assert_called_once()
|
mock_remove_pages.assert_called_once_with(
|
||||||
|
self.doc2.source_path,
|
||||||
|
mock.ANY,
|
||||||
|
[1, 3],
|
||||||
|
)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
||||||
self.assertEqual(task_kwargs["input_doc"].root_document_id, self.doc2.id)
|
self.assertEqual(task_kwargs["input_doc"].root_document_id, self.doc2.id)
|
||||||
@@ -1350,10 +1351,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.remove_pages")
|
||||||
def test_delete_pages_explicit_selection_uses_root_source_when_root_selected(
|
def test_delete_pages_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_open,
|
mock_remove_pages,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1362,9 +1363,6 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.delete_pages(
|
result = bulk_edit.delete_pages(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1373,23 +1371,26 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_open.assert_called_once_with(self.doc2.source_path)
|
self.assertEqual(mock_remove_pages.call_args.args[0], self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("documents.pdf_ops.remove_pages")
|
||||||
def test_delete_pages_with_error(self, mock_pdf_save, mock_consume_delay) -> None:
|
def test_delete_pages_with_error(
|
||||||
|
self,
|
||||||
|
mock_remove_pages,
|
||||||
|
mock_consume_delay,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Delete pages action is called with 1 document and 2 pages
|
- Delete pages action is called with 1 document and 2 pages
|
||||||
- PikePDF raises an error
|
- Removing the pages raises an error
|
||||||
THEN:
|
THEN:
|
||||||
- Save should be called once
|
|
||||||
- No new version should be enqueued
|
- No new version should be enqueued
|
||||||
"""
|
"""
|
||||||
mock_pdf_save.side_effect = Exception("Error saving PDF")
|
mock_remove_pages.side_effect = Exception("Error removing pages")
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [1, 3]
|
pages = [1, 3]
|
||||||
|
|
||||||
@@ -1419,6 +1420,41 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.group")
|
||||||
|
@mock.patch("documents.pdf_ops.build_pdfs")
|
||||||
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
|
def test_edit_pdf_maps_operations_to_outputs(
|
||||||
|
self,
|
||||||
|
mock_consume_file: mock.Mock,
|
||||||
|
mock_build_pdfs: mock.Mock,
|
||||||
|
mock_group: mock.Mock,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document
|
||||||
|
WHEN:
|
||||||
|
- edit_pdf is called with operations interleaved across two outputs,
|
||||||
|
some of them rotated
|
||||||
|
THEN:
|
||||||
|
- Each output is built from its own operations, in operation order,
|
||||||
|
with the requested rotation
|
||||||
|
"""
|
||||||
|
mock_build_pdfs.return_value = [self.doc2.source_path, self.doc2.source_path]
|
||||||
|
mock_group.return_value.delay.return_value = None
|
||||||
|
operations = [
|
||||||
|
{"page": 3, "doc": 1},
|
||||||
|
{"page": 1, "doc": 0, "rotate": 90},
|
||||||
|
{"page": 2, "doc": 1, "rotate": 180},
|
||||||
|
]
|
||||||
|
|
||||||
|
bulk_edit.edit_pdf([self.doc2.id], operations)
|
||||||
|
|
||||||
|
outputs = mock_build_pdfs.call_args.args[1]
|
||||||
|
self.assertEqual(
|
||||||
|
[specs for specs, _ in outputs],
|
||||||
|
[[PageSpec(1, 90)], [PageSpec(3), PageSpec(2, 180)]],
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group) -> None:
|
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group) -> None:
|
||||||
@@ -1534,12 +1570,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.new")
|
@mock.patch("documents.pdf_ops.build_pdfs")
|
||||||
@mock.patch("pikepdf.open")
|
|
||||||
def test_edit_pdf_explicit_selection_uses_root_source_when_root_selected(
|
def test_edit_pdf_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_open,
|
mock_build_pdfs,
|
||||||
mock_new,
|
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1548,12 +1582,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
fake_pdf = mock.MagicMock()
|
mock_build_pdfs.return_value = [Path("edited.pdf")]
|
||||||
fake_pdf.pages = [mock.Mock()]
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
output_pdf = mock.MagicMock()
|
|
||||||
output_pdf.pages = []
|
|
||||||
mock_new.return_value = output_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.edit_pdf(
|
result = bulk_edit.edit_pdf(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1563,7 +1592,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_open.assert_called_once_with(self.doc2.source_path)
|
self.assertEqual(mock_build_pdfs.call_args.args[0], self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@@ -1589,31 +1618,6 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
|
||||||
def test_edit_pdf_open_failure(
|
|
||||||
self,
|
|
||||||
mock_consume_file: mock.Mock,
|
|
||||||
mock_group: mock.Mock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing document
|
|
||||||
WHEN:
|
|
||||||
- edit_pdf fails to open PDF
|
|
||||||
THEN:
|
|
||||||
- Task group is not called
|
|
||||||
"""
|
|
||||||
doc_ids = [self.doc2.id]
|
|
||||||
operations = [
|
|
||||||
{"page": 9999}, # invalid page, forces error during PDF load
|
|
||||||
]
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
|
||||||
with self.assertRaises(Exception):
|
|
||||||
bulk_edit.edit_pdf(doc_ids, operations)
|
|
||||||
mock_group.assert_not_called()
|
|
||||||
mock_consume_file.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
|
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
|
||||||
@@ -1640,23 +1644,15 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("pikepdf.open")
|
|
||||||
def test_edit_pdf_rejects_invalid_operations(self, mock_open) -> None:
|
|
||||||
for operations in ([], [{"page": 1, "doc": 2**32}]):
|
|
||||||
with self.subTest(operations=operations):
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
|
||||||
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
|
|
||||||
bulk_edit.edit_pdf([self.doc2.id], operations)
|
|
||||||
|
|
||||||
mock_open.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
||||||
|
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
||||||
def test_remove_password_update_document(
|
def test_remove_password_update_document(
|
||||||
self,
|
self,
|
||||||
mock_open,
|
mock_needs_decrypt,
|
||||||
|
mock_decrypt,
|
||||||
mock_mkdtemp,
|
mock_mkdtemp,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_update_document,
|
mock_update_document,
|
||||||
@@ -1665,16 +1661,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-update"
|
temp_dir = self.dirs.scratch_dir / "remove-password-update"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
|
mock_decrypt.side_effect = self.fake_decrypt
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock(), mock.Mock()]
|
|
||||||
fake_pdf.is_encrypted = True
|
|
||||||
|
|
||||||
def save_side_effect(target_path):
|
|
||||||
Path(target_path).write_bytes(b"new pdf content")
|
|
||||||
|
|
||||||
fake_pdf.save.side_effect = save_side_effect
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1683,14 +1670,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(
|
mock_needs_decrypt.assert_called_once_with(doc.source_path)
|
||||||
mock_open.call_args_list,
|
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
||||||
[
|
|
||||||
mock.call(doc.source_path),
|
|
||||||
mock.call(doc.source_path, password="secret"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
fake_pdf.remove_unreferenced_resources.assert_called_once()
|
|
||||||
mock_update_document.assert_not_called()
|
mock_update_document.assert_not_called()
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
||||||
@@ -1703,40 +1684,15 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(task_kwargs["input_doc"].root_document_id, doc.id)
|
self.assertEqual(task_kwargs["input_doc"].root_document_id, doc.id)
|
||||||
self.assertIsNotNone(task_kwargs["overrides"])
|
self.assertIsNotNone(task_kwargs["overrides"])
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
|
||||||
@mock.patch("pikepdf.open")
|
|
||||||
def test_remove_password_update_document_skips_unencrypted_pdf(
|
|
||||||
self,
|
|
||||||
mock_open,
|
|
||||||
mock_mkdtemp,
|
|
||||||
mock_consume_delay,
|
|
||||||
) -> None:
|
|
||||||
doc = self.doc1
|
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.is_encrypted = False
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
|
||||||
[doc.id],
|
|
||||||
password="secret",
|
|
||||||
update_document=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
|
||||||
mock_open.assert_called_once_with(doc.source_path)
|
|
||||||
fake_pdf.remove_unreferenced_resources.assert_not_called()
|
|
||||||
fake_pdf.save.assert_not_called()
|
|
||||||
mock_mkdtemp.assert_not_called()
|
|
||||||
mock_consume_delay.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
||||||
|
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
||||||
def test_remove_password_update_document_uses_source_paths(
|
def test_remove_password_update_document_uses_source_paths(
|
||||||
self,
|
self,
|
||||||
mock_open,
|
mock_needs_decrypt,
|
||||||
|
mock_decrypt,
|
||||||
mock_mkdtemp,
|
mock_mkdtemp,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_update_document,
|
mock_update_document,
|
||||||
@@ -1747,14 +1703,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-source-file"
|
temp_dir = self.dirs.scratch_dir / "remove-password-source-file"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
|
mock_decrypt.side_effect = self.fake_decrypt
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
self.mock_password_required_pdf(mock_open, fake_pdf)
|
|
||||||
|
|
||||||
def save_side_effect(target_path):
|
|
||||||
Path(target_path).write_bytes(b"new pdf content")
|
|
||||||
|
|
||||||
fake_pdf.save.side_effect = save_side_effect
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1764,22 +1713,19 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(
|
mock_needs_decrypt.assert_called_once_with(source_file)
|
||||||
mock_open.call_args_list,
|
mock_decrypt.assert_called_once_with(source_file, mock.ANY, "secret")
|
||||||
[
|
|
||||||
mock.call(source_file),
|
|
||||||
mock.call(source_file, password="secret"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
mock_update_document.assert_not_called()
|
mock_update_document.assert_not_called()
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
||||||
|
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
||||||
def test_remove_password_explicit_selection_uses_root_source_when_root_selected(
|
def test_remove_password_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_open,
|
mock_needs_decrypt,
|
||||||
|
mock_decrypt,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1788,8 +1734,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="A version 1",
|
title="A version 1",
|
||||||
root_document=self.doc1,
|
root_document=self.doc1,
|
||||||
)
|
)
|
||||||
fake_pdf = mock.MagicMock()
|
mock_decrypt.return_value = Path("unprotected.pdf")
|
||||||
self.mock_password_required_pdf(mock_open, fake_pdf)
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[self.doc1.id],
|
[self.doc1.id],
|
||||||
@@ -1799,12 +1744,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(
|
mock_needs_decrypt.assert_called_once_with(self.doc1.source_path)
|
||||||
mock_open.call_args_list,
|
mock_decrypt.assert_called_once_with(
|
||||||
[
|
self.doc1.source_path,
|
||||||
mock.call(self.doc1.source_path),
|
mock.ANY,
|
||||||
mock.call(self.doc1.source_path, password="secret"),
|
"secret",
|
||||||
],
|
|
||||||
)
|
)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@@ -1812,10 +1756,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
||||||
|
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
||||||
def test_remove_password_creates_consumable_document(
|
def test_remove_password_creates_consumable_document(
|
||||||
self,
|
self,
|
||||||
mock_open: mock.Mock,
|
mock_needs_decrypt: mock.Mock,
|
||||||
|
mock_decrypt: mock.Mock,
|
||||||
mock_mkdtemp: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
@@ -1825,15 +1771,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password"
|
temp_dir = self.dirs.scratch_dir / "remove-password"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
|
mock_decrypt.side_effect = self.fake_decrypt
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
|
||||||
self.mock_password_required_pdf(mock_open, fake_pdf)
|
|
||||||
|
|
||||||
def save_side_effect(target_path: Path) -> None:
|
|
||||||
target_path.write_bytes(b"password removed")
|
|
||||||
|
|
||||||
fake_pdf.save.side_effect = save_side_effect
|
|
||||||
mock_group.return_value.delay.return_value = None
|
mock_group.return_value.delay.return_value = None
|
||||||
|
|
||||||
user = User.objects.create(username="owner")
|
user = User.objects.create(username="owner")
|
||||||
@@ -1848,13 +1786,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(
|
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
||||||
mock_open.call_args_list,
|
|
||||||
[
|
|
||||||
mock.call(doc.source_path),
|
|
||||||
mock.call(doc.source_path, password="secret"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_called_once()
|
||||||
call_kwargs = mock_consume_file.call_args.kwargs
|
call_kwargs = mock_consume_file.call_args.kwargs
|
||||||
consumable_document = call_kwargs["input_doc"]
|
consumable_document = call_kwargs["input_doc"]
|
||||||
@@ -1876,21 +1808,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.chord")
|
@mock.patch("documents.bulk_edit.chord")
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=False)
|
||||||
def test_remove_password_skips_unencrypted_pdf_without_queueing(
|
def test_remove_password_skips_unencrypted_pdf_without_queueing(
|
||||||
self,
|
self,
|
||||||
mock_open: mock.Mock,
|
mock_needs_decrypt: mock.Mock,
|
||||||
mock_mkdtemp: mock.Mock,
|
mock_decrypt: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
mock_chord: mock.Mock,
|
mock_chord: mock.Mock,
|
||||||
mock_delete: mock.Mock,
|
mock_delete: mock.Mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
doc = self.doc2
|
doc = self.doc2
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.is_encrypted = False
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1900,10 +1829,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_open.assert_called_once_with(doc.source_path)
|
mock_needs_decrypt.assert_called_once_with(doc.source_path)
|
||||||
fake_pdf.remove_unreferenced_resources.assert_not_called()
|
mock_decrypt.assert_not_called()
|
||||||
fake_pdf.save.assert_not_called()
|
|
||||||
mock_mkdtemp.assert_not_called()
|
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_chord.assert_not_called()
|
mock_chord.assert_not_called()
|
||||||
@@ -1914,10 +1841,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
||||||
|
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
||||||
def test_remove_password_deletes_original(
|
def test_remove_password_deletes_original(
|
||||||
self,
|
self,
|
||||||
mock_open: mock.Mock,
|
mock_needs_decrypt: mock.Mock,
|
||||||
|
mock_decrypt: mock.Mock,
|
||||||
mock_mkdtemp: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
@@ -1928,15 +1857,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-delete"
|
temp_dir = self.dirs.scratch_dir / "remove-password-delete"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
|
mock_decrypt.side_effect = self.fake_decrypt
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
|
||||||
self.mock_password_required_pdf(mock_open, fake_pdf)
|
|
||||||
|
|
||||||
def save_side_effect(target_path: Path) -> None:
|
|
||||||
target_path.write_bytes(b"password removed")
|
|
||||||
|
|
||||||
fake_pdf.save.side_effect = save_side_effect
|
|
||||||
mock_chord.return_value.delay.return_value = None
|
mock_chord.return_value.delay.return_value = None
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
@@ -1948,23 +1869,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(
|
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
||||||
mock_open.call_args_list,
|
|
||||||
[
|
|
||||||
mock.call(doc.source_path),
|
|
||||||
mock.call(doc.source_path, password="secret"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_called_once()
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_chord.assert_called_once()
|
mock_chord.assert_called_once()
|
||||||
mock_chord.return_value.delay.assert_called_once()
|
mock_chord.return_value.delay.assert_called_once()
|
||||||
mock_delete.si.assert_called_once_with([doc.id])
|
mock_delete.si.assert_called_once_with([doc.id])
|
||||||
|
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch(
|
||||||
def test_remove_password_open_failure(self, mock_open: mock.Mock) -> None:
|
"documents.pdf_ops.decrypt_pdf",
|
||||||
mock_open.side_effect = RuntimeError("wrong password")
|
side_effect=RuntimeError("wrong password"),
|
||||||
|
)
|
||||||
|
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
||||||
|
def test_remove_password_failure_raises_value_error(
|
||||||
|
self,
|
||||||
|
mock_needs_decrypt: mock.Mock,
|
||||||
|
mock_decrypt: mock.Mock,
|
||||||
|
) -> None:
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
with self.assertRaises(ValueError) as exc:
|
with self.assertRaises(ValueError) as exc:
|
||||||
bulk_edit.remove_password([self.doc1.id], password="secret")
|
bulk_edit.remove_password([self.doc1.id], password="secret")
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"""
|
||||||
|
Tests for documents.pdf_ops.
|
||||||
|
|
||||||
|
These use real PDFs from the sample directories. No database, Celery or mocks.
|
||||||
|
Pages are compared by a hash of their content stream, so page identity and order
|
||||||
|
are easy to assert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents import pdf_ops
|
||||||
|
from documents.pdf_ops import PageSpec
|
||||||
|
|
||||||
|
SRC_ROOT = Path(__file__).parents[2]
|
||||||
|
SAMPLES = Path(__file__).parent / "samples"
|
||||||
|
THREE_PAGES = SAMPLES / "documents" / "originals" / "0000002.pdf"
|
||||||
|
TWELVE_PAGES = SAMPLES / "barcodes" / "split-by-asn-2.pdf"
|
||||||
|
ENCRYPTED = SAMPLES / "password-is-test.pdf"
|
||||||
|
SIGNED = SRC_ROOT / "paperless" / "tests" / "samples" / "tesseract" / "signed.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def _page_fingerprint(page: pikepdf.Page) -> str:
|
||||||
|
contents = page.obj.get("/Contents")
|
||||||
|
assert contents is not None, "sample page has no /Contents"
|
||||||
|
streams = list(contents) if isinstance(contents, pikepdf.Array) else [contents]
|
||||||
|
return hashlib.sha256(b"".join(s.read_bytes() for s in streams)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprints(path: Path) -> list[str]:
|
||||||
|
with pikepdf.open(path) as pdf:
|
||||||
|
return [_page_fingerprint(page) for page in pdf.pages]
|
||||||
|
|
||||||
|
|
||||||
|
def rotations(path: Path) -> list[int]:
|
||||||
|
with pikepdf.open(path) as pdf:
|
||||||
|
return [int(page.obj.get("/Rotate", 0)) for page in pdf.pages]
|
||||||
|
|
||||||
|
|
||||||
|
def docinfo_keys(path: Path) -> set[str]:
|
||||||
|
with pikepdf.open(path) as pdf:
|
||||||
|
return set(pdf.docinfo.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def constant(path: Path) -> Callable[[], Path]:
|
||||||
|
return lambda: path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def source_fingerprints() -> list[str]:
|
||||||
|
fps = fingerprints(THREE_PAGES)
|
||||||
|
assert len(set(fps)) == 3, "sample must have three distinct pages"
|
||||||
|
return fps
|
||||||
|
|
||||||
|
|
||||||
|
class TestRotatePdf:
|
||||||
|
def test_rotation_is_relative_and_applies_to_every_page(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- It is rotated by 90 degrees, then the result is rotated by 90 again
|
||||||
|
THEN:
|
||||||
|
- Every page is rotated relative to its current rotation
|
||||||
|
- The page content itself is unchanged
|
||||||
|
"""
|
||||||
|
once = tmp_path / "once.pdf"
|
||||||
|
twice = tmp_path / "twice.pdf"
|
||||||
|
|
||||||
|
pdf_ops.rotate_pdf(THREE_PAGES, once, 90)
|
||||||
|
pdf_ops.rotate_pdf(once, twice, 90)
|
||||||
|
|
||||||
|
assert rotations(once) == [90, 90, 90]
|
||||||
|
assert rotations(twice) == [180, 180, 180]
|
||||||
|
assert fingerprints(twice) == fingerprints(THREE_PAGES)
|
||||||
|
|
||||||
|
def test_keeps_document_info(self, tmp_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A PDF with document info
|
||||||
|
WHEN:
|
||||||
|
- It is rotated
|
||||||
|
THEN:
|
||||||
|
- The document info is still present in the output
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
pdf_ops.rotate_pdf(THREE_PAGES, dst, 90)
|
||||||
|
|
||||||
|
assert "/Creator" in docinfo_keys(dst)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemovePages:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("pages", "kept"),
|
||||||
|
[
|
||||||
|
pytest.param([2], [0, 2], id="single"),
|
||||||
|
pytest.param([3, 1], [1], id="unordered"),
|
||||||
|
pytest.param([2, 2], [0, 2], id="duplicates-remove-once"),
|
||||||
|
pytest.param([], [0, 1, 2], id="empty-keeps-everything"),
|
||||||
|
pytest.param([1, 2, 3], [], id="every-page"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_removes_only_the_requested_pages(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_fingerprints: list[str],
|
||||||
|
pages: list[int],
|
||||||
|
kept: list[int],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- Pages are removed, in any order and possibly repeated
|
||||||
|
THEN:
|
||||||
|
- Exactly the other pages remain, in their original order
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
pdf_ops.remove_pages(THREE_PAGES, dst, pages)
|
||||||
|
|
||||||
|
assert fingerprints(dst) == [source_fingerprints[i] for i in kept]
|
||||||
|
|
||||||
|
def test_keeps_document_info(self, tmp_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A PDF with document info
|
||||||
|
WHEN:
|
||||||
|
- A page is removed
|
||||||
|
THEN:
|
||||||
|
- The document info is still present in the output
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
pdf_ops.remove_pages(THREE_PAGES, dst, [1])
|
||||||
|
|
||||||
|
assert "/Creator" in docinfo_keys(dst)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad_page",
|
||||||
|
[
|
||||||
|
pytest.param(0, id="zero"),
|
||||||
|
pytest.param(-1, id="negative"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_pages_below_one(self, tmp_path: Path, bad_page: int) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- Pages are removed and one of them is below 1
|
||||||
|
THEN:
|
||||||
|
- ValueError is raised
|
||||||
|
- No output file is written
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="start at 1"):
|
||||||
|
pdf_ops.remove_pages(THREE_PAGES, dst, [1, bad_page])
|
||||||
|
|
||||||
|
assert not dst.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPdfs:
|
||||||
|
def test_selects_and_orders_pages(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_fingerprints: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- One output is built from pages 3 then 1
|
||||||
|
THEN:
|
||||||
|
- The output has those pages in that order
|
||||||
|
- Its path is returned
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
written = pdf_ops.build_pdfs(
|
||||||
|
THREE_PAGES,
|
||||||
|
[([PageSpec(3), PageSpec(1)], constant(dst))],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert written == [dst]
|
||||||
|
assert fingerprints(dst) == [source_fingerprints[2], source_fingerprints[0]]
|
||||||
|
|
||||||
|
def test_rotates_only_the_requested_pages(self, tmp_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- One output is built with a different rotation on each page
|
||||||
|
THEN:
|
||||||
|
- Each output page has exactly the rotation requested for it
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
pdf_ops.build_pdfs(
|
||||||
|
THREE_PAGES,
|
||||||
|
[([PageSpec(1), PageSpec(2, 90), PageSpec(3, 180)], constant(dst))],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rotations(dst) == [0, 90, 180]
|
||||||
|
|
||||||
|
def test_writes_one_file_per_output_in_order(self, tmp_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A twelve page PDF
|
||||||
|
WHEN:
|
||||||
|
- Two outputs are built from different page ranges
|
||||||
|
THEN:
|
||||||
|
- Two files are written and returned in output order
|
||||||
|
- Each holds exactly its own pages
|
||||||
|
"""
|
||||||
|
first = tmp_path / "first.pdf"
|
||||||
|
second = tmp_path / "second.pdf"
|
||||||
|
source = fingerprints(TWELVE_PAGES)
|
||||||
|
|
||||||
|
written = pdf_ops.build_pdfs(
|
||||||
|
TWELVE_PAGES,
|
||||||
|
[
|
||||||
|
([PageSpec(p) for p in (1, 2, 3)], constant(first)),
|
||||||
|
([PageSpec(p) for p in range(4, 13)], constant(second)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert written == [first, second]
|
||||||
|
assert fingerprints(first) == source[:3]
|
||||||
|
assert fingerprints(second) == source[3:]
|
||||||
|
|
||||||
|
def test_empty_page_list_writes_a_zero_page_file(self, tmp_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- An output with no pages is built
|
||||||
|
THEN:
|
||||||
|
- A PDF with zero pages is written
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
pdf_ops.build_pdfs(THREE_PAGES, [([], constant(dst))])
|
||||||
|
|
||||||
|
assert fingerprints(dst) == []
|
||||||
|
|
||||||
|
def test_destination_is_not_requested_when_a_page_is_out_of_range(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- Several outputs are built
|
||||||
|
- A later output refers to a page past the end
|
||||||
|
THEN:
|
||||||
|
- IndexError is raised
|
||||||
|
- No destination was requested for any output, including earlier valid ones
|
||||||
|
"""
|
||||||
|
requested: list[Path] = []
|
||||||
|
|
||||||
|
def make_dst() -> Path:
|
||||||
|
requested.append(tmp_path / "out.pdf")
|
||||||
|
return requested[-1]
|
||||||
|
|
||||||
|
with pytest.raises(IndexError):
|
||||||
|
pdf_ops.build_pdfs(
|
||||||
|
THREE_PAGES,
|
||||||
|
[([PageSpec(1)], make_dst), ([PageSpec(99)], make_dst)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert requested == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad_page",
|
||||||
|
[
|
||||||
|
pytest.param(0, id="zero"),
|
||||||
|
pytest.param(-1, id="negative"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_pages_below_one_before_opening_anything(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
bad_page: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A three page PDF
|
||||||
|
WHEN:
|
||||||
|
- Several outputs are built and a later one has a page below 1
|
||||||
|
THEN:
|
||||||
|
- ValueError is raised
|
||||||
|
- No destination was requested for any output
|
||||||
|
"""
|
||||||
|
requested: list[Path] = []
|
||||||
|
|
||||||
|
def make_dst() -> Path:
|
||||||
|
requested.append(tmp_path / "out.pdf")
|
||||||
|
return requested[-1]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="start at 1"):
|
||||||
|
pdf_ops.build_pdfs(
|
||||||
|
THREE_PAGES,
|
||||||
|
[([PageSpec(1)], make_dst), ([PageSpec(bad_page)], make_dst)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert requested == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidatePageOperations:
|
||||||
|
def test_returns_the_output_count(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Operations that all target the default output
|
||||||
|
WHEN:
|
||||||
|
- They are validated
|
||||||
|
THEN:
|
||||||
|
- One output document is reported
|
||||||
|
"""
|
||||||
|
operations = [{"page": 1}, {"page": 2}, {"page": 3}]
|
||||||
|
|
||||||
|
assert pdf_ops.validate_page_operations(operations, single_output=True) == 1
|
||||||
|
|
||||||
|
def test_gap_in_output_indices_counts_up_to_the_highest(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Operations that target outputs 0 and 2 but never 1
|
||||||
|
WHEN:
|
||||||
|
- They are validated
|
||||||
|
THEN:
|
||||||
|
- Three output documents are reported
|
||||||
|
"""
|
||||||
|
operations = [
|
||||||
|
{"page": 1, "doc": 0},
|
||||||
|
{"page": 2, "doc": 2},
|
||||||
|
{"page": 3, "doc": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
count = pdf_ops.validate_page_operations(operations, single_output=False)
|
||||||
|
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
def test_empty_operations_are_rejected(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- No operations
|
||||||
|
WHEN:
|
||||||
|
- They are validated
|
||||||
|
THEN:
|
||||||
|
- ValueError is raised
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError, match="index is out of bounds"):
|
||||||
|
pdf_ops.validate_page_operations([], single_output=False)
|
||||||
|
|
||||||
|
def test_multiple_outputs_rejected_when_single_output_required(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Operations that target two outputs
|
||||||
|
WHEN:
|
||||||
|
- They are validated with a single output required
|
||||||
|
THEN:
|
||||||
|
- ValueError is raised
|
||||||
|
"""
|
||||||
|
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Multiple output documents"):
|
||||||
|
pdf_ops.validate_page_operations(operations, single_output=True)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"doc",
|
||||||
|
[
|
||||||
|
pytest.param(-1, id="negative"),
|
||||||
|
pytest.param(2, id="equal-to-operation-count"),
|
||||||
|
pytest.param(2**32, id="huge"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_output_index_out_of_bounds(self, doc: int) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Two operations, one with an output index that is out of bounds
|
||||||
|
WHEN:
|
||||||
|
- They are validated
|
||||||
|
THEN:
|
||||||
|
- ValueError is raised
|
||||||
|
"""
|
||||||
|
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": doc}]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="index is out of bounds"):
|
||||||
|
pdf_ops.validate_page_operations(operations, single_output=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecrypt:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(ENCRYPTED, True, id="password-required"),
|
||||||
|
pytest.param(SIGNED, True, id="opens-without-password-but-encrypted"),
|
||||||
|
pytest.param(THREE_PAGES, False, id="not-encrypted"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_needs_decrypt(self, path: Path, *, expected: bool) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A PDF that is encrypted, or encrypted but openable, or plain
|
||||||
|
WHEN:
|
||||||
|
- needs_decrypt is asked about it
|
||||||
|
THEN:
|
||||||
|
- Only the unencrypted PDF reports False
|
||||||
|
"""
|
||||||
|
assert pdf_ops.needs_decrypt(path) is expected
|
||||||
|
|
||||||
|
def test_decrypt_writes_an_unencrypted_copy(self, tmp_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A password protected PDF
|
||||||
|
WHEN:
|
||||||
|
- It is decrypted with the correct password
|
||||||
|
THEN:
|
||||||
|
- The path from make_dst is returned
|
||||||
|
- The written copy no longer needs decrypting
|
||||||
|
"""
|
||||||
|
dst = tmp_path / "out.pdf"
|
||||||
|
|
||||||
|
result = pdf_ops.decrypt_pdf(ENCRYPTED, constant(dst), "test")
|
||||||
|
|
||||||
|
assert result == dst
|
||||||
|
assert pdf_ops.needs_decrypt(dst) is False
|
||||||
|
|
||||||
|
def test_wrong_password_raises_and_never_requests_a_destination(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A password protected PDF
|
||||||
|
WHEN:
|
||||||
|
- It is decrypted with the wrong password
|
||||||
|
THEN:
|
||||||
|
- PasswordError is raised
|
||||||
|
- No destination was requested
|
||||||
|
"""
|
||||||
|
requested: list[Path] = []
|
||||||
|
|
||||||
|
def make_dst() -> Path:
|
||||||
|
requested.append(tmp_path / "out.pdf")
|
||||||
|
return requested[-1]
|
||||||
|
|
||||||
|
with pytest.raises(pikepdf.PasswordError):
|
||||||
|
pdf_ops.decrypt_pdf(ENCRYPTED, make_dst, "wrong")
|
||||||
|
|
||||||
|
assert requested == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfMerger:
|
||||||
|
def test_pages_are_appended_in_the_order_added(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_fingerprints: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A reordered PDF and the original three page PDF
|
||||||
|
WHEN:
|
||||||
|
- Both are added to a merger in that order and saved
|
||||||
|
THEN:
|
||||||
|
- The output holds all pages in the order they were added
|
||||||
|
"""
|
||||||
|
reordered = tmp_path / "reordered.pdf"
|
||||||
|
merged = tmp_path / "merged.pdf"
|
||||||
|
pdf_ops.build_pdfs(
|
||||||
|
THREE_PAGES,
|
||||||
|
[([PageSpec(3), PageSpec(1)], constant(reordered))],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pdf_ops.PdfMerger() as merger:
|
||||||
|
merger.add(reordered)
|
||||||
|
merger.add(THREE_PAGES)
|
||||||
|
merger.save(merged)
|
||||||
|
|
||||||
|
assert fingerprints(merged) == [
|
||||||
|
source_fingerprints[2],
|
||||||
|
source_fingerprints[0],
|
||||||
|
*source_fingerprints,
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_output_version_is_at_least_the_highest_source_version(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Two PDFs with different PDF versions
|
||||||
|
WHEN:
|
||||||
|
- Both are added to a merger and saved
|
||||||
|
THEN:
|
||||||
|
- The output version is at least the highest source version
|
||||||
|
"""
|
||||||
|
merged = tmp_path / "merged.pdf"
|
||||||
|
with pikepdf.open(TWELVE_PAGES) as pdf:
|
||||||
|
source_versions = [pdf.pdf_version]
|
||||||
|
with pikepdf.open(THREE_PAGES) as pdf:
|
||||||
|
source_versions.append(pdf.pdf_version)
|
||||||
|
|
||||||
|
with pdf_ops.PdfMerger() as merger:
|
||||||
|
merger.add(TWELVE_PAGES)
|
||||||
|
merger.add(THREE_PAGES)
|
||||||
|
merger.save(merged)
|
||||||
|
|
||||||
|
with pikepdf.open(merged) as pdf:
|
||||||
|
assert pdf.pdf_version >= max(source_versions)
|
||||||
Reference in new issue
Block a user