Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Unlike other viewers that bundle heavy rendering engines (often adding 30-50MB),
### 3. Native "Open With" Integration
The app integrates directly with your system's file manager. You don't need to open the app first; just tap your file in "Downloads" or "Files" and select Simple Reader.

### 4. Modern & Intuitive Design
A completely redesigned home screen featuring smooth animations, gradient aesthetics, and quick access shortcuts, all while maintaining the app's lightweight nature.

## Supported Formats

* **PDF (.pdf)**: High-performance rendering using Android's built-in PDF engine. Supports pinch-to-zoom and page virtualization.
Expand All @@ -32,7 +35,7 @@ The app integrates directly with your system's file manager. You don't need to o
For developers interested in the implementation:

* **Language**: 100% Kotlin.
* **UI**: Jetpack Compose (Material 3).
* **UI**: Jetpack Compose (Material 3) with custom animations and dynamic gradient themes.
* **Architecture**: MVVM / Clean Architecture.
* **PDF**: Implemented via `android.graphics.pdf.PdfRenderer` with Bitmap recycling and LruCache for memory efficiency.
* **Office**: Uses a ProGuard-stripped version of Apache POI. Complex binaries are parsed to HTML/CSS for rendering in a restricted WebView.
Expand Down
5 changes: 4 additions & 1 deletion README_VI.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Không giống như các trình xem khác thường đóng gói các công cụ
### 3. Tích Hợp "Mở Bằng" (Open With) Tự Nhiên
Ứng dụng tích hợp trực tiếp với trình quản lý tệp của hệ thống. Bạn không cần phải mở ứng dụng trước; chỉ cần nhấn vào tệp của bạn trong "Tải xuống" hoặc "Tệp" và chọn Simple Reader.

### 4. Thiết Kế Hiện Đại & Trực Quan
Màn hình chính được thiết kế lại hoàn toàn với các hiệu ứng hoạt hình mượt mà, giao diện gradient đẹp mắt và các phím tắt truy cập nhanh, trong khi vẫn giữ được sự nhẹ nhàng vốn có của ứng dụng.

## Các Định Dạng Được Hỗ Trợ

* **PDF (.pdf)**: Hiển thị hiệu suất cao sử dụng công cụ PDF tích hợp sẵn của Android. Hỗ trợ thu phóng bằng hai ngón tay và ảo hóa trang.
Expand All @@ -32,7 +35,7 @@ Không giống như các trình xem khác thường đóng gói các công cụ
Dành cho các lập trình viên quan tâm đến việc triển khai:

* **Ngôn Ngữ**: 100% Kotlin.
* **Giao Diện (UI)**: Jetpack Compose (Material 3).
* **Giao Diện (UI)**: Jetpack Compose (Material 3) với các hiệu ứng hoạt hình tùy chỉnh và chủ đề gradient động.
* **Kiến Trúc**: MVVM / Kiến Trúc Sạch (Clean Architecture).
* **PDF**: Được triển khai thông qua `android.graphics.pdf.PdfRenderer` với việc tái sử dụng Bitmap và LruCache để tiết kiệm bộ nhớ.
* **Office**: Sử dụng phiên bản rút gọn của Apache POI (đã qua ProGuard). Các tệp nhị phân phức tạp được phân tích thành HTML/CSS để hiển thị trong WebView bị hạn chế quyền.
Expand Down
209 changes: 201 additions & 8 deletions app/src/main/java/com/zeq/simple/reader/parser/DocxParser.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.zeq.simple.reader.parser

import android.util.Base64
import android.util.Log
import org.apache.poi.xwpf.usermodel.UnderlinePatterns
import org.apache.poi.xwpf.usermodel.XWPFDocument
import org.apache.poi.xwpf.usermodel.XWPFParagraph
import org.apache.poi.xwpf.usermodel.XWPFPicture
import org.apache.poi.xwpf.usermodel.XWPFTable
import java.io.BufferedInputStream
import java.io.InputStream
Expand Down Expand Up @@ -67,6 +69,46 @@ class DocxParser {
strong { font-weight: 600; }
em { font-style: italic; }
u { text-decoration: underline; }
img {
max-width: 100%;
height: auto;
display: block;
margin: 12px 0;
border-radius: 4px;
}
.image-container {
text-align: center;
margin: 16px 0;
}
.page-break {
border-top: 2px dashed #9e9e9e;
margin: 24px 0;
padding-top: 24px;
position: relative;
}
.page-break::before {
content: '--- Page Break ---';
display: block;
text-align: center;
color: #757575;
font-size: 12px;
font-weight: 500;
background-color: #ffffff;
padding: 4px 12px;
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
}
@media (prefers-color-scheme: dark) {
.page-break {
border-color: #616161;
}
.page-break::before {
background-color: #121212;
color: #9e9e9e;
}
}
</style>
</head>
<body>
Expand Down Expand Up @@ -124,28 +166,179 @@ class DocxParser {
}

/**
* Processes a single paragraph, applying basic styling.
* Processes a single paragraph, applying basic styling and extracting images.
*/
private fun processParagraph(paragraph: XWPFParagraph): String {
val text = paragraph.text?.trim() ?: return ""
if (text.isEmpty()) return "<p>&nbsp;</p>\n"
val builder = StringBuilder()

// Check for page break before this paragraph
if (hasPageBreakBefore(paragraph)) {
builder.append("<div class=\"page-break\"></div>\n")
}

// Check for embedded images first
val images = extractImagesFromParagraph(paragraph)
if (images.isNotEmpty()) {
images.forEach { imageHtml ->
builder.append(imageHtml)
}
// Also include any text in the paragraph
val text = paragraph.text?.trim() ?: ""
if (text.isNotEmpty()) {
val escapedText = escapeHtml(text)
val style = paragraph.style?.lowercase() ?: ""
when {
style.contains("heading1") || style.contains("title") ->
builder.append("<h1>$escapedText</h1>\n")
style.contains("heading2") ->
builder.append("<h2>$escapedText</h2>\n")
style.contains("heading3") ->
builder.append("<h3>$escapedText</h3>\n")
else -> {
val formattedText = processInlineFormatting(paragraph)
builder.append("<p>$formattedText</p>\n")
}
}
}
return builder.toString()
}

val text = paragraph.text?.trim() ?: ""
if (text.isEmpty()) {
// Even empty paragraphs can have page breaks
return if (builder.isNotEmpty()) builder.toString() else "<p>&nbsp;</p>\n"
}

val escapedText = escapeHtml(text)

// Detect heading styles
val style = paragraph.style?.lowercase() ?: ""
return when {
when {
style.contains("heading1") || style.contains("title") ->
"<h1>$escapedText</h1>\n"
builder.append("<h1>$escapedText</h1>\n")
style.contains("heading2") ->
"<h2>$escapedText</h2>\n"
builder.append("<h2>$escapedText</h2>\n")
style.contains("heading3") ->
"<h3>$escapedText</h3>\n"
builder.append("<h3>$escapedText</h3>\n")
else -> {
// Apply inline formatting
val formattedText = processInlineFormatting(paragraph)
"<p>$formattedText</p>\n"
builder.append("<p>$formattedText</p>\n")
}
}

return builder.toString()
}

/**
* Checks if a paragraph has a page break before it.
*/
private fun hasPageBreakBefore(paragraph: XWPFParagraph): Boolean {
try {
// Check paragraph properties for page break
val ctp = paragraph.ctp
if (ctp != null && ctp.pPr != null) {
val pageBreakBefore = ctp.pPr.pageBreakBefore
if (pageBreakBefore != null) {
try {
// Try to get boolean value
val isPageBreak = pageBreakBefore.`val` == true
if (isPageBreak) return true
} catch (e: Exception) {
// If val property doesn't work, presence of pageBreakBefore element itself indicates true
return true
}
}
}

// Check runs for explicit page break
paragraph.runs?.forEach { run ->
try {
val ctr = run.ctr
if (ctr != null && ctr.brList != null) {
for (br in ctr.brList) {
if (br != null && br.type != null) {
val brType = br.type.toString()
if (brType == "PAGE" || brType.contains("page", ignoreCase = true)) {
return true
}
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Error checking run for page break", e)
}
}
} catch (e: Exception) {
Log.w(TAG, "Error checking paragraph for page break", e)
}

return false
}

/**
* Extracts images from paragraph runs and converts to base64 HTML img tags.
*/
private fun extractImagesFromParagraph(paragraph: XWPFParagraph): List<String> {
val images = mutableListOf<String>()

try {
paragraph.runs.forEach { run ->
val embeddedPictures = run.embeddedPictures
embeddedPictures?.forEach { picture ->
try {
val imageHtml = convertPictureToHtml(picture)
if (imageHtml != null) {
images.add(imageHtml)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to process embedded picture", e)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to extract images from paragraph", e)
}

return images
}

/**
* Converts XWPFPicture to HTML img tag with base64 encoded data.
*/
private fun convertPictureToHtml(picture: XWPFPicture): String? {
try {
val pictureData = picture.pictureData ?: return null
val imageBytes = pictureData.data ?: return null

// Convert to base64
val base64Image = Base64.encodeToString(imageBytes, Base64.NO_WRAP)

Comment on lines +311 to +316
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOCX images are embedded as base64 data URIs, which can dramatically increase the resulting HTML size and memory usage for documents with large/many images. Consider applying size/count limits or downscaling before encoding to reduce the risk of OOM and improve load time in WebView.

Copilot uses AI. Check for mistakes.
// Get MIME type from extension
val extension = pictureData.suggestFileExtension() ?: "png"
val mimeType = when (extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"bmp" -> "image/bmp"
"webp" -> "image/webp"
else -> "image/png"
}

// Get dimensions if available
val width = picture.width
val height = picture.depth

val dimensionAttr = if (width > 0 && height > 0) {
" style=\"max-width: 100%; width: ${width}px; height: auto;\""
} else {
""
}

Comment on lines +328 to +337
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XWPFPicture.width/depth are not pixel values in Apache POI (they’re typically document units such as EMUs). Using them directly as px in inline CSS can make images render at absurd sizes. Prefer relying on CSS max-width:100% only, or convert POI units to pixels via POI’s Units helpers before emitting a width: style.

Suggested change
// Get dimensions if available
val width = picture.width
val height = picture.depth
val dimensionAttr = if (width > 0 && height > 0) {
" style=\"max-width: 100%; width: ${width}px; height: auto;\""
} else {
""
}
// Use responsive styling without assuming POI dimensions are pixels
val dimensionAttr = " style=\"max-width: 100%; height: auto;\""

Copilot uses AI. Check for mistakes.
return "<div class=\"image-container\"><img src=\"data:$mimeType;base64,$base64Image\"$dimensionAttr alt=\"Embedded image\" /></div>\n"
} catch (e: Exception) {
Log.e(TAG, "Failed to convert picture to HTML", e)
return null
}
}

Expand Down
Loading
Loading