feat: page deletion, redaction and page rotation
This commit is contained in:
Binary file not shown.
@@ -169,5 +169,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def("save_incremental", [](const pdfengine::PdfDocument& self) {
|
||||
std::vector<uint8_t> res = get_or_throw(self.saveIncremental());
|
||||
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
|
||||
})
|
||||
.def("save_full", [](const pdfengine::PdfDocument& self) {
|
||||
std::vector<uint8_t> res = get_or_throw(self.saveFull());
|
||||
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
|
||||
});
|
||||
}
|
||||
@@ -120,6 +120,9 @@ public:
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||
saveIncremental() const = 0;
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||
saveFull() const = 0;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1108,7 +1108,67 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "redaction") {
|
||||
spdlog::info("Parsed redaction edit operation (stub)");
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("redaction operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
double width = data.value("width", 0.0);
|
||||
double height = data.value("height", 0.0);
|
||||
std::string fillColor = data.value("fillColor", "#ffffff");
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for redaction", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
// 1. Permanently remove underlying page objects overlapping with selection
|
||||
int count = FPDFPage_CountObjects(page);
|
||||
for (int i = count - 1; i >= 0; --i) {
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i);
|
||||
if (obj) {
|
||||
float left = 0, bottom = 0, right = 0, top = 0;
|
||||
if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) {
|
||||
// Check overlap
|
||||
if (!(left > x + width || right < x || bottom > y + height || top < y)) {
|
||||
FPDFPage_RemoveObject(page, obj);
|
||||
FPDFPageObj_Destroy(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Place white cover rectangle over region
|
||||
FPDF_PAGEOBJECT rectObj = FPDFPageObj_CreateNewRect(
|
||||
static_cast<float>(x),
|
||||
static_cast<float>(y),
|
||||
static_cast<float>(width),
|
||||
static_cast<float>(height)
|
||||
);
|
||||
if (!rectObj) {
|
||||
spdlog::error("Failed to create redaction cover rectangle object");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
unsigned int r = 255, g = 255, b = 255;
|
||||
parseHexColor(fillColor, r, g, b);
|
||||
|
||||
FPDFPageObj_SetFillColor(rectObj, r, g, b, 255);
|
||||
FPDFPath_SetDrawMode(rectObj, FPDF_FILLMODE_WINDING, 0);
|
||||
FPDFPage_InsertObject(page, rectObj);
|
||||
|
||||
// 3. Regenerate page content stream
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after redaction");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "image_overlay") {
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("image_overlay operation missing 'data' object");
|
||||
@@ -1295,6 +1355,21 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveIncremental
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveFull() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!doc_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
VectorWriter writer;
|
||||
if (!FPDF_SaveWithVersion(doc_, &writer, 0, 14)) {
|
||||
return std::unexpected(EngineError::WriteFailed);
|
||||
}
|
||||
return writer.buffer;
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
|
||||
@@ -75,6 +75,7 @@ public:
|
||||
|
||||
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
|
||||
|
||||
private:
|
||||
NativeDocHandle doc_ = nullptr;
|
||||
|
||||
@@ -435,6 +435,69 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
|
||||
EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ApplyRedactionAndFullSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
// Verify text exists initially
|
||||
{
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto textRes = (*pageRes)->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
EXPECT_NE(textRes->find("Hello"), std::string::npos);
|
||||
}
|
||||
|
||||
// Redact the entire page bounds to remove all objects
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_redact_test_1",
|
||||
"type": "redaction",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"width": 612.0,
|
||||
"height": 792.0,
|
||||
"fillColor": "#ffffff"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveFull();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto textRes = newPage->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
|
||||
// The text should be completely gone
|
||||
EXPECT_EQ(textRes->find("Hello"), std::string::npos);
|
||||
EXPECT_EQ(textRes->find("world"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ApplyImageOverlayAndIncrementalSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
|
||||
+54
-1
@@ -147,7 +147,7 @@ function App() {
|
||||
setSidebarTab('annotations');
|
||||
};
|
||||
|
||||
const handleRotateClick = async (newRotValue?: number) => {
|
||||
const handleRotateClick = async (_newRotValue?: number) => {
|
||||
if (!selectedDocId || !activeDoc) return;
|
||||
const pageIndex = currentPage;
|
||||
try {
|
||||
@@ -258,6 +258,58 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedactArea = async (pageIndex: number, bounds: { x: number; y: number; width: number; height: number }) => {
|
||||
if (!selectedDocId || !activeDoc) return;
|
||||
|
||||
const pageInfo = activeDoc.pages?.[pageIndex];
|
||||
const pageHeight = pageInfo ? pageInfo.height : 792;
|
||||
|
||||
// Convert coordinates: flip Y axis for PDF space (origin bottom-left)
|
||||
const x = bounds.x / zoom;
|
||||
const y = pageHeight - (bounds.y + bounds.height) / zoom;
|
||||
const width = bounds.width / zoom;
|
||||
const height = bounds.height / zoom;
|
||||
|
||||
if (!confirm("Are you sure you want to permanently redact this area? All text, images, and vectors underneath will be permanently deleted from the file structure. This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const op = {
|
||||
id: `redact_${Math.random().toString(36).substring(2, 11)}`,
|
||||
type: 'redaction' as const,
|
||||
pageIndex: pageIndex,
|
||||
data: {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fillColor: '#ffffff',
|
||||
}
|
||||
};
|
||||
|
||||
const result = await gatewayService.applyEdits(selectedDocId, [op]);
|
||||
if (result.success) {
|
||||
// Re-fetch document list so sidebar updates
|
||||
const docs = await gatewayService.listDocuments();
|
||||
setDocuments(docs);
|
||||
|
||||
// Select the new document ID
|
||||
setSelectedDocId(result.newDocumentId);
|
||||
|
||||
// Reset tool back to select
|
||||
setActiveTool('select');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to apply redaction:', err);
|
||||
alert('Failed to apply redaction. Make sure the gateway is connected.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
|
||||
{/* Top Navigation / Toolbar */}
|
||||
@@ -317,6 +369,7 @@ function App() {
|
||||
searchQuery={searchQuery}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onPageVisible={setCurrentPage}
|
||||
onRedactArea={handleRedactArea}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 h-full flex flex-col items-center justify-center bg-slate-900 gap-3 text-slate-400">
|
||||
|
||||
@@ -167,6 +167,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
>
|
||||
Signature
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onActiveToolChange('redact')}
|
||||
className={`tool-btn redact ${activeTool === 'redact' ? 'active' : ''}`}
|
||||
title="Permanently Redact Area"
|
||||
>
|
||||
Redact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SEARCH BAR */}
|
||||
|
||||
@@ -319,6 +319,12 @@ body {
|
||||
border: 1px solid rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
.tool-btn.redact.active {
|
||||
background: rgba(244, 63, 94, 0.2);
|
||||
color: #fda4af;
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface RenderParams {
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
import type { Point, Rect } from './coordinateMapping';
|
||||
import type { Point } from './coordinateMapping';
|
||||
|
||||
export interface TextOverlayData {
|
||||
text: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { PageInfo } from '../lib/gatewayService';
|
||||
import { RedactionLayer } from './RedactionLayer';
|
||||
|
||||
interface PDFViewerProps {
|
||||
documentId: string;
|
||||
@@ -19,6 +20,7 @@ interface PDFViewerProps {
|
||||
searchQuery?: string;
|
||||
onAnnotationAdded?: (anno: Annotation) => void;
|
||||
onPageVisible?: (pageIndex: number) => void;
|
||||
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
||||
}
|
||||
|
||||
interface PageLayout {
|
||||
@@ -44,6 +46,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
searchQuery,
|
||||
onAnnotationAdded,
|
||||
onPageVisible,
|
||||
onRedactArea,
|
||||
}, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
|
||||
@@ -289,13 +292,25 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
|
||||
{/* Text Selection Dragging Layer */}
|
||||
<SelectionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
onTextSelected={(text, bbox) => handleTextSelection(text, bbox)}
|
||||
/>
|
||||
{activeTool !== 'redact' && (
|
||||
<SelectionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
onTextSelected={(text, bbox) => handleTextSelection(text, bbox)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Redaction Area Selection Layer */}
|
||||
{activeTool === 'redact' && (
|
||||
<RedactionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* signature/ink overlay tool layer */}
|
||||
<OverlayLayer
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import type { Point, Rect } from '../lib/coordinateMapping';
|
||||
|
||||
interface RedactionLayerProps {
|
||||
pageIndex: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onRedactionSelected: (bounds: Rect) => void;
|
||||
}
|
||||
|
||||
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
width,
|
||||
height,
|
||||
onRedactionSelected,
|
||||
}) => {
|
||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||
const [redactBox, setRedactBox] = useState<Rect | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
setDragStart({ x, y });
|
||||
setRedactBox({ x, y, width: 0, height: 0 });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!dragStart || !redactBox || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const newBox: Rect = {
|
||||
x: Math.min(dragStart.x, x),
|
||||
y: Math.min(dragStart.y, y),
|
||||
width: Math.abs(dragStart.x - x),
|
||||
height: Math.abs(dragStart.y - y),
|
||||
};
|
||||
|
||||
setRedactBox(newBox);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (redactBox && redactBox.width > 5 && redactBox.height > 5) {
|
||||
onRedactionSelected(redactBox);
|
||||
}
|
||||
setDragStart(null);
|
||||
setRedactBox(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="redaction-layer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
cursor: 'crosshair',
|
||||
zIndex: 25,
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{redactBox && (
|
||||
<div
|
||||
className="redaction-box-drag"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${redactBox.x}px`,
|
||||
top: `${redactBox.y}px`,
|
||||
width: `${redactBox.width}px`,
|
||||
height: `${redactBox.height}px`,
|
||||
border: '2px dashed var(--color-error, #f43f5e)',
|
||||
backgroundColor: 'rgba(244, 63, 94, 0.15)',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: '0 0 12px rgba(244, 63, 94, 0.25)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -250,7 +250,8 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
edits_json = json.dumps(req_dict)
|
||||
doc.apply_edits(edits_json)
|
||||
|
||||
new_bytes = doc.save_incremental()
|
||||
has_redaction = any(op.get("type") == "redaction" for op in req_dict.get("operations", []))
|
||||
new_bytes = doc.save_full() if has_redaction else doc.save_incremental()
|
||||
|
||||
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
|
||||
|
||||
|
||||
@@ -271,6 +271,46 @@ def test_apply_page_rotation_and_incremental_save(client: TestClient):
|
||||
assert render_resp.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_apply_redaction_and_full_save(client: TestClient):
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
# Apply redaction edit operation
|
||||
edits_payload = {
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_route_redact_test_123",
|
||||
"type": "redaction",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"width": 612.0,
|
||||
"height": 792.0,
|
||||
"fillColor": "#ffffff",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
|
||||
assert edits_resp.status_code == 200
|
||||
payload = edits_resp.json()
|
||||
assert payload["success"] is True
|
||||
new_doc_id = payload["newDocumentId"]
|
||||
assert new_doc_id != doc_id
|
||||
|
||||
# Verify text is deleted from the page
|
||||
text_resp = client.get(f"/documents/{new_doc_id}/pages/0/text")
|
||||
assert text_resp.status_code == 200
|
||||
assert "hello" not in text_resp.json()["text"].lower()
|
||||
assert "world" not in text_resp.json()["text"].lower()
|
||||
|
||||
|
||||
def test_apply_page_deletion_and_incremental_save(client: TestClient):
|
||||
# We need a 2-page PDF to test deletion
|
||||
two_pages_pdf = CORPUS_DIR / "basic" / "hello_world_2_pages.pdf"
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import openpyxl
|
||||
import copy
|
||||
import shutil
|
||||
import os
|
||||
|
||||
def update_timeline():
|
||||
excel_path = "PDF Editor Timeline.xlsx"
|
||||
backup_path = "PDF Editor Timeline.xlsx.bak"
|
||||
|
||||
if os.path.exists(excel_path):
|
||||
shutil.copyfile(excel_path, backup_path)
|
||||
print(f"Created backup at {backup_path}")
|
||||
else:
|
||||
print(f"Error: {excel_path} not found.")
|
||||
return
|
||||
|
||||
wb = openpyxl.load_workbook(excel_path)
|
||||
|
||||
tasks_data = {
|
||||
"FreeType + HarfBuzz wrappers + LRU glyph cache": [
|
||||
("FreeType Integration", "1 Day", "22-05-26", "22-05-26", "FreeType library initialized; FT_Face successfully loaded from font buffer", "", "Base integration for the entire typography engine.", "Wk 2", "Wk 2", "Critical"),
|
||||
("HarfBuzz Integration", "1 Day", "22-05-26", "22-05-26", "HarfBuzz library linked; hb_font and hb_buffer created from FT_Face", "", "Required for shaping Unicode and layout engine.", "Wk 2", "Wk 2", "Critical"),
|
||||
("FontFace Architecture", "1-2 Days", "22-05-26", "22-05-26", "RAII wrapper for FT_Face and hb_font_t; clean memory management", "", "Encapsulates font face lifecycle in C++.", "Wk 2", "Wk 2", "High"),
|
||||
("FontLoader System", "1 Day", "22-05-26", "22-05-26", "Loads raw font binary data (TTF/OTF) from memory buffer into FontFace", "", "Supports loading from system or custom paths.", "Wk 2", "Wk 2", "High"),
|
||||
("FontResolver System", "1-2 Days", "22-05-26", "22-05-26", "Resolves font requests (family, weight, italic) to best matching local font", "", "Determines which font to load dynamically.", "Wk 2", "Wk 2", "High"),
|
||||
("Font Metrics Engine", "1 Day", "22-05-26", "23-05-26", "Calculates ascender, descender, line gap, and scale factors", "", "Ensures vertical text alignment is correct.", "Wk 2", "Wk 3", "High"),
|
||||
("Advance Width Calculation", "1 Day", "22-05-26", "23-05-26", "Retrieves horizontal advances for individual glyphs via FreeType / HarfBuzz", "", "Needed to correctly advance cursor during text rendering.", "Wk 2", "Wk 3", "High"),
|
||||
("Glyph Lookup System", "1 Day", "22-05-26", "23-05-26", "Maps unicode character points to glyph indices accurately", "", "Core of character representation.", "Wk 2", "Wk 3", "High"),
|
||||
("Missing Glyph Detection", "1 Day", "23-05-26", "23-05-26", "Identifies when a font lacks a glyph for a codepoint; triggers fallback", "", "Prevents rendering blank spaces / boxes (tofu).", "Wk 3", "Wk 3", "Med"),
|
||||
("LRU Glyph Cache", "1-2 Days", "22-05-26", "23-05-26", "Caches rendered glyph bitmaps; cache hits >90% on text corpus", "", "Crucial for text rendering performance.", "Wk 2", "Wk 3", "Critical"),
|
||||
("Runtime Font Cache", "1 Day", "22-05-26", "23-05-26", "Stores loaded FontFace instances globally to prevent reloading same font resource", "", "Speeds up multi-page renders.", "Wk 2", "Wk 3", "High"),
|
||||
("Thread-Safe Font Engine", "1 Day", "22-05-26", "23-05-26", "All font engine components are thread-safe; concurrent access protected by mutexes", "", "Supports background parsing and parallel rasterization.", "Wk 2", "Wk 3", "High"),
|
||||
("Memory Management & RAII", "1 Day", "22-05-26", "22-05-26", "Zero memory leaks under ASan when loading and unloading fonts", "", "Strict enforcement of ownership semantics.", "Wk 2", "Wk 2", "Critical"),
|
||||
("HarfBuzz Ligature & Kerning Support", "1 Day", "22-05-26", "23-05-26", "Applies advanced open type features (liga, kern) correctly during text shaping", "", "Ensures professional typesetting look.", "Wk 2", "Wk 3", "High"),
|
||||
("Unicode Text Shaping Pipeline", "2 Days", "22-05-26", "23-05-26", "Shapes incoming UTF-8 string into shaped glyph run with correct advance and offsets", "", "Translates abstract string to concrete spatial coordinates.", "Wk 2", "Wk 3", "Critical")
|
||||
],
|
||||
"PDF font resource loader (Type1/TrueType/CID)": [
|
||||
("PDF Font Resource Loader", "2 Days", "23-05-26", "25-05-26", "Loads font resource streams directly from PDF dictionaries", "", "Essential for rendering documents with embedded fonts.", "Wk 3", "Wk 3", "Critical"),
|
||||
("TrueType Font Support", "1 Day", "23-05-26", "24-05-26", "Parses and renders standard TrueType (.ttf) fonts", "", "Handles glyph outline rendering for TrueType format.", "Wk 3", "Wk 3", "High"),
|
||||
("OpenType Font Support", "1 Day", "24-05-26", "24-05-26", "Parses and renders standard OpenType (.otf) fonts", "", "Handles CFF outlines and advanced OpenType tables.", "Wk 3", "Wk 3", "High"),
|
||||
("Type1 Font Support", "1 Day", "24-05-26", "25-05-26", "Parses and renders legacy Type 1 postscript fonts", "", "Needed for legacy PDF documents.", "Wk 3", "Wk 3", "High"),
|
||||
("CIDFont Support", "2 Days", "25-05-26", "26-05-26", "Correctly maps character IDs to glyph indices for CIDFonts", "", "Crucial for large character sets (CJK).", "Wk 3", "Wk 4", "Critical"),
|
||||
("Embedded Font Loading", "1 Day", "23-05-26", "24-05-26", "Extracts and loads embedded font file streams (FontFile, FontFile2, FontFile3)", "", "Prevents font missing errors.", "Wk 3", "Wk 3", "Critical"),
|
||||
("Subset Font Loading", "1-2 Days", "24-05-26", "26-05-26", "Successfully loads fonts containing only a subset of glyphs", "", "Common optimization in PDFs; requires robust glyph-index mapping.", "Wk 3", "Wk 4", "High"),
|
||||
("Font Metadata Extraction", "1 Day", "23-05-26", "24-05-26", "Parses font names, flags, subtypes, and panose data", "", "Useful for debugging and font substitution matching.", "Wk 3", "Wk 3", "Med"),
|
||||
("CJK (Chinese/Japanese/Korean) Support", "2 Days", "24-05-26", "26-05-26", "Successfully renders double-byte character sets and vertical writing parameters", "", "Crucial for international document rendering.", "Wk 3", "Wk 4", "High"),
|
||||
("PDFium ↔ Font Engine Integration", "2 Days", "24-05-26", "26-05-26", "Synchronizes PDFium's font extraction with our FreeType/HarfBuzz pipeline", "", "Binds the layout interpreter to our rendering path.", "Wk 3", "Wk 4", "Critical"),
|
||||
("Lazy Font Loading", "1 Day", "24-05-26", "26-05-26", "Loads font resource streams only when a glyph from that font needs to be rendered", "", "Optimizes document open times and memory consumption.", "Wk 3", "Wk 4", "Med")
|
||||
],
|
||||
"Font substitution system (Liberation fonts)": [
|
||||
("Font Fallback System", "1-2 Days", "25-05-26", "27-05-26", "Dynamically selects a fallback font when primary font lacks glyph", "", "Ensures all characters render.", "Wk 4", "Wk 4", "High"),
|
||||
("Font Substitution System (Liberation Fonts)", "2 Days", "25-05-26", "01-06-26", "Substitutes missing system fonts (e.g. Arial) with metric-compatible Liberation fonts", "", "Keeps layout intact.", "Wk 4", "Wk 4", "High")
|
||||
]
|
||||
}
|
||||
|
||||
def copy_cell_style(src_cell, dest_cell):
|
||||
if src_cell.has_style:
|
||||
dest_cell.font = copy.copy(src_cell.font)
|
||||
dest_cell.fill = copy.copy(src_cell.fill)
|
||||
dest_cell.border = copy.copy(src_cell.border)
|
||||
dest_cell.alignment = copy.copy(src_cell.alignment)
|
||||
dest_cell.number_format = copy.copy(src_cell.number_format)
|
||||
dest_cell.protection = copy.copy(src_cell.protection)
|
||||
|
||||
# 1. Update 📋 Master Timeline
|
||||
ws = wb['📋 Master Timeline']
|
||||
|
||||
headers = [ws.cell(row=6, column=col).value for col in range(1, ws.max_column + 1)]
|
||||
has_dates = "Start Date" in headers
|
||||
|
||||
if not has_dates:
|
||||
print("Inserting Start Date and End Date columns in Master Timeline...")
|
||||
# Insert 2 columns at Column K (index 11)
|
||||
ws.insert_cols(11, 2)
|
||||
ws.cell(row=6, column=11).value = "Start Date"
|
||||
ws.cell(row=6, column=12).value = "End Date"
|
||||
headers = [ws.cell(row=6, column=col).value for col in range(1, ws.max_column + 1)]
|
||||
copy_cell_style(ws.cell(row=6, column=10), ws.cell(row=6, column=11))
|
||||
copy_cell_style(ws.cell(row=6, column=10), ws.cell(row=6, column=12))
|
||||
|
||||
parent_rows = {}
|
||||
for r in range(7, ws.max_row + 1):
|
||||
task_name = ws.cell(row=r, column=3).value
|
||||
if task_name in tasks_data:
|
||||
parent_rows[task_name] = r
|
||||
|
||||
sorted_parents = sorted(parent_rows.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
total_inserted = 0
|
||||
for parent_name, row_idx in sorted_parents:
|
||||
subtasks = tasks_data[parent_name]
|
||||
num_sub = len(subtasks)
|
||||
print(f"Found parent '{parent_name}' at row {row_idx}. Inserting {num_sub} subtasks below it.")
|
||||
|
||||
ws.insert_rows(row_idx + 1, num_sub)
|
||||
|
||||
for i, sub in enumerate(subtasks):
|
||||
curr_row = row_idx + 1 + i
|
||||
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
|
||||
|
||||
ws.cell(row=curr_row, column=1).value = "P1"
|
||||
ws.cell(row=curr_row, column=2).value = "Phase 1"
|
||||
ws.cell(row=curr_row, column=3).value = f" - {task_t}" # indented slightly for readability
|
||||
ws.cell(row=curr_row, column=4).value = "Dev 3"
|
||||
ws.cell(row=curr_row, column=5).value = wk_s
|
||||
ws.cell(row=curr_row, column=6).value = wk_e
|
||||
ws.cell(row=curr_row, column=7).value = dur
|
||||
ws.cell(row=curr_row, column=8).value = "Done"
|
||||
ws.cell(row=curr_row, column=9).value = pri
|
||||
ws.cell(row=curr_row, column=10).value = 1.0 # 100% done
|
||||
ws.cell(row=curr_row, column=11).value = sd
|
||||
ws.cell(row=curr_row, column=12).value = ed
|
||||
ws.cell(row=curr_row, column=13).value = sc
|
||||
ws.cell(row=curr_row, column=14).value = dep if dep else None
|
||||
ws.cell(row=curr_row, column=15).value = notes
|
||||
|
||||
for col in range(1, 16):
|
||||
copy_cell_style(ws.cell(row=row_idx, column=col), ws.cell(row=curr_row, column=col))
|
||||
|
||||
total_inserted += num_sub
|
||||
|
||||
summary_row_idx = None
|
||||
for r in range(7, ws.max_row + 1):
|
||||
val = ws.cell(row=r, column=1).value
|
||||
if val and isinstance(val, str) and "=COUNTIF" in val:
|
||||
summary_row_idx = r
|
||||
break
|
||||
|
||||
if summary_row_idx:
|
||||
print(f"Summary row found at {summary_row_idx}. Updating formulas to include new rows.")
|
||||
old_end_row = 69
|
||||
new_end_row = 69 + total_inserted
|
||||
|
||||
# Update formulas
|
||||
# A: Dev counts formula
|
||||
# D: Priority counts formula
|
||||
# H: Status counts formula
|
||||
# K: average completion formula
|
||||
|
||||
# A
|
||||
f_a = ws.cell(row=summary_row_idx, column=1).value
|
||||
ws.cell(row=summary_row_idx, column=1).value = f_a.replace(f"D{old_end_row}", f"D{new_end_row}")
|
||||
|
||||
# D
|
||||
f_d = ws.cell(row=summary_row_idx, column=4).value
|
||||
ws.cell(row=summary_row_idx, column=4).value = f_d.replace(f"I{old_end_row}", f"I{new_end_row}")
|
||||
|
||||
# H
|
||||
f_h = ws.cell(row=summary_row_idx, column=8).value
|
||||
ws.cell(row=summary_row_idx, column=8).value = f_h.replace(f"H{old_end_row}", f"H{new_end_row}")
|
||||
|
||||
# K -> shifted to M (13)
|
||||
f_k = ws.cell(row=summary_row_idx, column=13).value
|
||||
if f_k and "J7:J69" in f_k:
|
||||
ws.cell(row=summary_row_idx, column=13).value = f_k.replace(f"J{old_end_row}", f"J{new_end_row}")
|
||||
|
||||
print("Master Timeline formulas updated successfully.")
|
||||
|
||||
if '🔍 Phase Detail' in wb.sheetnames:
|
||||
ws_det = wb['🔍 Phase Detail']
|
||||
parent_rows_det = {}
|
||||
for r in range(7, ws_det.max_row + 1):
|
||||
task_name = ws_det.cell(row=r, column=3).value
|
||||
if task_name in tasks_data:
|
||||
parent_rows_det[task_name] = r
|
||||
|
||||
sorted_parents_det = sorted(parent_rows_det.items(), key=lambda x: x[1], reverse=True)
|
||||
for parent_name, row_idx in sorted_parents_det:
|
||||
subtasks = tasks_data[parent_name]
|
||||
num_sub = len(subtasks)
|
||||
ws_det.insert_rows(row_idx + 1, num_sub)
|
||||
for i, sub in enumerate(subtasks):
|
||||
curr_row = row_idx + 1 + i
|
||||
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
|
||||
ws_det.cell(row=curr_row, column=1).value = "P1"
|
||||
ws_det.cell(row=curr_row, column=2).value = "Phase 1"
|
||||
ws_det.cell(row=curr_row, column=3).value = f" - {task_t}"
|
||||
ws_det.cell(row=curr_row, column=4).value = "Dev 3"
|
||||
ws_det.cell(row=curr_row, column=5).value = wk_s
|
||||
ws_det.cell(row=curr_row, column=6).value = wk_e
|
||||
ws_det.cell(row=curr_row, column=7).value = dur
|
||||
ws_det.cell(row=curr_row, column=8).value = "Done"
|
||||
ws_det.cell(row=curr_row, column=9).value = pri
|
||||
ws_det.cell(row=curr_row, column=10).value = 1.0
|
||||
ws_det.cell(row=curr_row, column=11).value = sc
|
||||
|
||||
for col in range(1, 12):
|
||||
copy_cell_style(ws_det.cell(row=row_idx, column=col), ws_det.cell(row=curr_row, column=col))
|
||||
print("Phase Detail sheet updated successfully.")
|
||||
|
||||
if '📅 Gantt' in wb.sheetnames:
|
||||
ws_gantt = wb['📅 Gantt']
|
||||
parent_rows_g = {}
|
||||
for r in range(5, ws_gantt.max_row + 1):
|
||||
task_name = ws_gantt.cell(row=r, column=2).value
|
||||
if task_name in tasks_data:
|
||||
parent_rows_g[task_name] = r
|
||||
|
||||
sorted_parents_g = sorted(parent_rows_g.items(), key=lambda x: x[1], reverse=True)
|
||||
for parent_name, row_idx in sorted_parents_g:
|
||||
subtasks = tasks_data[parent_name]
|
||||
num_sub = len(subtasks)
|
||||
ws_gantt.insert_rows(row_idx + 1, num_sub)
|
||||
for i, sub in enumerate(subtasks):
|
||||
curr_row = row_idx + 1 + i
|
||||
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
|
||||
ws_gantt.cell(row=curr_row, column=1).value = "P1"
|
||||
ws_gantt.cell(row=curr_row, column=2).value = f" - {task_t}"
|
||||
ws_gantt.cell(row=curr_row, column=3).value = "Dev 3"
|
||||
ws_gantt.cell(row=curr_row, column=4).value = dur
|
||||
|
||||
wk_s_num = int(wk_s.split()[1]) # 'Wk 2' -> 2
|
||||
wk_e_num = int(wk_e.split()[1]) # 'Wk 3' -> 3
|
||||
for wk in range(1, 21):
|
||||
col_idx = 4 + wk
|
||||
if wk_s_num <= wk <= wk_e_num:
|
||||
ws_gantt.cell(row=curr_row, column=col_idx).value = "▶"
|
||||
else:
|
||||
ws_gantt.cell(row=curr_row, column=col_idx).value = None
|
||||
|
||||
for col in range(1, 25):
|
||||
copy_cell_style(ws_gantt.cell(row=row_idx, column=col), ws_gantt.cell(row=curr_row, column=col))
|
||||
print("Gantt sheet updated successfully.")
|
||||
|
||||
wb.save(excel_path)
|
||||
print(f"Successfully saved updated timeline to {excel_path}!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_timeline()
|
||||
Reference in New Issue
Block a user