diff --git a/PDF Editor Timeline.xlsx.bak b/PDF Editor Timeline.xlsx.bak new file mode 100644 index 0000000..b473c78 Binary files /dev/null and b/PDF Editor Timeline.xlsx.bak differ diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index f88c437..37b1af3 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -169,5 +169,9 @@ PYBIND11_MODULE(pdfengine, m) { .def("save_incremental", [](const pdfengine::PdfDocument& self) { std::vector res = get_or_throw(self.saveIncremental()); return py::bytes(reinterpret_cast(res.data()), res.size()); + }) + .def("save_full", [](const pdfengine::PdfDocument& self) { + std::vector res = get_or_throw(self.saveFull()); + return py::bytes(reinterpret_cast(res.data()), res.size()); }); } \ No newline at end of file diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 9601173..4c52e2b 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -120,6 +120,9 @@ public: [[nodiscard]] virtual std::expected, EngineError> saveIncremental() const = 0; + + [[nodiscard]] virtual std::expected, EngineError> + saveFull() const = 0; }; } \ No newline at end of file diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index f3e14bd..792ccc4 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -1108,7 +1108,67 @@ std::expected 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(x), + static_cast(y), + static_cast(width), + static_cast(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, EngineError> PdfiumDocument::saveIncremental #endif } +std::expected, 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, EngineError> PdfiumPage::getFonts() const { #ifdef PDFENGINE_WITH_PDFIUM if (!page_) { diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 7526a05..2681be6 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -75,6 +75,7 @@ public: std::expected applyEdits(const std::string& editsJson) override; std::expected, EngineError> saveIncremental() const override; + std::expected, EngineError> saveFull() const override; private: NativeDocHandle doc_ = nullptr; diff --git a/engine/tests/document_test.cpp b/engine/tests/document_test.cpp index bacdd26..7989313 100644 --- a/engine/tests/document_test.cpp +++ b/engine/tests/document_test.cpp @@ -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"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fccf633..448477b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (
{/* Top Navigation / Toolbar */} @@ -317,6 +369,7 @@ function App() { searchQuery={searchQuery} onAnnotationAdded={handleAnnotationAdded} onPageVisible={setCurrentPage} + onRedactArea={handleRedactArea} /> ) : (
diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 943d30f..9cdf33a 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -167,6 +167,14 @@ export const Toolbar: React.FC = ({ > Signature + +
{/* SEARCH BAR */} diff --git a/frontend/src/index.css b/frontend/src/index.css index ce98c6f..676f112 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 708b7f5..5e47b22 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -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; diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index d2bccaf..c059fd2 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -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(({ searchQuery, onAnnotationAdded, onPageVisible, + onRedactArea, }, ref) => { const containerRef = useRef(null); const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 }); @@ -289,13 +292,25 @@ export const PDFViewer = React.forwardRef(({ /> {/* Text Selection Dragging Layer */} - handleTextSelection(text, bbox)} - /> + {activeTool !== 'redact' && ( + handleTextSelection(text, bbox)} + /> + )} + + {/* Redaction Area Selection Layer */} + {activeTool === 'redact' && ( + onRedactArea?.(page.index, bounds)} + /> + )} {/* signature/ink overlay tool layer */} void; +} + +export const RedactionLayer: React.FC = ({ + width, + height, + onRedactionSelected, +}) => { + const [dragStart, setDragStart] = useState(null); + const [redactBox, setRedactBox] = useState(null); + const containerRef = useRef(null); + + const handleMouseDown = (e: React.MouseEvent) => { + 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) => { + 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 ( +
+ {redactBox && ( +
+ )} +
+ ); +}; diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 7588d8c..871bd88 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -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) diff --git a/gateway/tests/test_routes.py b/gateway/tests/test_routes.py index 03fb8d9..a12d2ed 100644 --- a/gateway/tests/test_routes.py +++ b/gateway/tests/test_routes.py @@ -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" diff --git a/scripts/update_timeline.py b/scripts/update_timeline.py new file mode 100644 index 0000000..7b718c9 --- /dev/null +++ b/scripts/update_timeline.py @@ -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()