diff --git a/.env.example b/.env.example index 50a5a4e..b061ea4 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,10 @@ -JWT_KEY=xxx \ No newline at end of file +JWT_KEY=PLACEHOLDER_REPLACE_WITH_STRONG_KEY_MIN_32_CHARS_BEFORE_USE + +REDIS_URL=redis:6379 + +REDIS_HOST=redis +REDIS_PORT=6379 + +SERVER_PORT=5000 + +COMPOSE_PROJECT_NAME=osmodoc diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template_api.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template_api.md index 3606bb1..897711a 100644 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template_api.md +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template_api.md @@ -2,7 +2,7 @@ ### Pre-requisites -- [ ] I have gone through the Contributing guidelines for [Submitting a Pull Request (PR)](https://github.com/OsmosysSoftware/document-service/blob/main/CONTRIBUTING.md#submitting-a-pull-request-pr) and ensured that this is not a duplicate PR. +- [ ] I have gone through the Contributing guidelines for [Submitting a Pull Request (PR)](https://github.com/OsmosysSoftware/osmodoc/blob/main/CONTRIBUTING.md#submitting-a-pull-request-pr) and ensured that this is not a duplicate PR. - [ ] I have performed unit testing for the new feature added or updated to ensure the new features added are working as expected. - [ ] I have performed preliminary testing to ensure that any existing features are not impacted and any new features are working as expected as a whole. - [ ] I have added/updated the `.env.example` file with the required values as applicable. diff --git a/.gitignore b/.gitignore index 3b1832a..7d5e33a 100644 --- a/.gitignore +++ b/.gitignore @@ -352,3 +352,6 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# Docker mount volumes +Temp/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2433ab4..0002c1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,5 @@ -# Contributing to DocumentService -Welcome to the document-service project! We appreciate your interest in contributing to the project and making it even better. As a contributor, +# Contributing to OsmoDoc +Welcome to the osmodoc project! We appreciate your interest in contributing to the project and making it even better. As a contributor, please follow the guidelines outlined below: ## Table of contents @@ -13,8 +13,8 @@ please follow the guidelines outlined below: ## Got a question or problem? **If you have questions or encounter problems, please refrain from opening issues for general support questions**. GitHub issues are primarily for bug -reports and feature requests. For general questions and support, consider using [Stack Overflow](https://stackoverflow.com/questions/tagged/document-service) -and tag your questions with the `document-service` tag. Here's why Stack Overflow is a preferred platform: +reports and feature requests. For general questions and support, consider using [Stack Overflow](https://stackoverflow.com/questions/tagged/osmodoc) +and tag your questions with the `osmodoc` tag. Here's why Stack Overflow is a preferred platform: - Questions and answers are publicly available, helping others. - The voting system on Stack Overflow highlights the best answers. @@ -23,8 +23,8 @@ To save time for both you and us, we will close issues related to general suppor ## Found any issues and bugs -If you find a bug in the source code, you can help us by [submitting an issue](https://github.com/OsmosysSoftware/document-service/issues/new) -to our GitHub Repository. Even better, you can submit a [pull request](https://github.com/OsmosysSoftware/document-service/pulls) with a fix. +If you find a bug in the source code, you can help us by [submitting an issue](https://github.com/OsmosysSoftware/osmodoc/issues/new) +to our GitHub Repository. Even better, you can submit a [pull request](https://github.com/OsmosysSoftware/osmodoc/pulls) with a fix. ## Submission guidelines @@ -34,19 +34,19 @@ Before you submit an issue, please check the issue tracker to see if a similar i For us to address and fix a bug, we need to reproduce it. Thus when submitting a bug report, we will ask for a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Providing a live, reproducible scenario helps us understand the issue better. Information to include: -- The version of the document-service you are using. +- The version of the osmodoc you are using. - Any third-party libraries and their versions. - A use-case that demonstrates the issue. Without a minimal reproduction, we may need to close the issue due to insufficient information. -You can file new issues using our [new issue form](https://github.com/OsmosysSoftware/document-service/issues/new). +You can file new issues using our [new issue form](https://github.com/OsmosysSoftware/osmodoc/issues/new). ### Submitting a pull request (PR) Before submitting a Pull Request (PR), please follow these guidelines: -1. Search GitHub [pull requests](https://github.com/OsmosysSoftware/document-service/pulls) to ensure there is no open or closed PR +1. Search GitHub [pull requests](https://github.com/OsmosysSoftware/osmodoc/pulls) to ensure there is no open or closed PR related to your submission. 2. Fork this repository. 3. Make your changes in a new Git branch. @@ -63,12 +63,12 @@ Before submitting a Pull Request (PR), please follow these guidelines: ```shell git push origin my-fix-branch ``` -7. Send a pull request to the `document-service:main`. +7. Send a pull request to the `osmodoc:main`. -- **If we suggest changes, then:** - - Make the required updates. - - Ensure that your changes do not break existing functionality or introduce new issues. - - Rebase your branch and force push to your GitHub repository. This will update your Pull Request. +- **If we suggest changes, then:** + - � Make the required updates. + - � Ensure that your changes do not break existing functionality or introduce new issues. + - � Rebase your branch and force push to your GitHub repository. This will update your Pull Request. That's it! Thank you for your contribution! @@ -90,7 +90,7 @@ To ensure consistency throughout the source code, follow these rules as you work ## Commit message guidelines In this project, we have specific rules for formatting our Git commit messages. These guidelines result in more readable messages that are easy -to follow when reviewing the project's history. Additionally, we use these commit messages to **generate the document-service change log**. +to follow when reviewing the project's history. Additionally, we use these commit messages to **generate the osmodoc change log**. ### Commit message format @@ -110,7 +110,7 @@ on GitHub as well as in various git tools. Footer should contain a [closing reference to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if any. -Samples: (even more [samples](https://github.com/OsmosysSoftware/document-service/commits/main)) +Samples: (even more [samples](https://github.com/OsmosysSoftware/osmodoc/commits/main)) `docs: update change log to beta.5` `fix: need to depend on latest rxjs and zone.js` diff --git a/Dockerfile b/Dockerfile index 8deee30..0a54882 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,47 +2,51 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /app -# Expose the desired port -ENV ASPNETCORE_ENVIRONMENT=Development -EXPOSE 5000 - -# Build configuration variable. Debug as default -ENV BUILD_CONFIGURATION=Debug +# Build configuration variable. Debug for development, Release for production +ENV BUILD_CONFIGURATION=Release +ENV ASPNETCORE_ENVIRONMENT=Production -# Copy data -COPY ["DocumentService.API/DocumentService.API.csproj", "DocumentService.API/"] -COPY ["DocumentService/DocumentService.csproj", "DocumentService/"] +# Copy the solution and project files +COPY *.sln ./ +COPY ./OsmoDoc.API/OsmoDoc.API.csproj OsmoDoc.API/ +COPY ./OsmoDoc/OsmoDoc.csproj OsmoDoc/ -# Restore the project dependencies -RUN dotnet restore "./DocumentService.API/./DocumentService.API.csproj" -RUN dotnet restore "./DocumentService/./DocumentService.csproj" +# Restore dependencies +RUN dotnet restore OsmoDoc.API/OsmoDoc.API.csproj -# Copy the rest of the data -COPY . . -WORKDIR "/app/DocumentService.API" +# Copy required project files +COPY ./OsmoDoc.API/ OsmoDoc.API/ +COPY ./OsmoDoc/ OsmoDoc/ +COPY .env . # Build the project and store artifacts in /out folder -RUN dotnet publish "./DocumentService.API.csproj" -c BUILD_CONFIGURATION -o /app/out +RUN dotnet publish OsmoDoc.API/OsmoDoc.API.csproj -c $BUILD_CONFIGURATION -o out # Use the official ASP.NET runtime image as the base image FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base + WORKDIR /app +ENV ASPNETCORE_URLS=http://+:5000 + # Copy the published artifacts from the build stage COPY --from=build /app/out . # Install only necessary dependencies for wkhtmltopdf, Node.js and npm -RUN apt-get update \ - && apt-get install -y --fix-missing wkhtmltopdf \ - && apt-get install -y --fix-missing nodejs \ - && apt-get install -y --no-install-recommends npm \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + wkhtmltopdf \ + nodejs \ + npm && \ + rm -rf /var/lib/apt/lists/* # Install wkhtmltopdf and allow execute access RUN chmod 755 /usr/bin/wkhtmltopdf -# Install ejs globally without unnecessary dependencies +# Install EJS globally RUN npm install -g --only=prod ejs +# Expose the API port +EXPOSE 5000 + # Set the entry point for the container -ENTRYPOINT ["dotnet", "DocumentService.API.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "OsmoDoc.API.dll"] \ No newline at end of file diff --git a/DocumentService.API/Helpers/AutoMappingProfile.cs b/DocumentService.API/Helpers/AutoMappingProfile.cs deleted file mode 100644 index a9cef5c..0000000 --- a/DocumentService.API/Helpers/AutoMappingProfile.cs +++ /dev/null @@ -1,13 +0,0 @@ -using AutoMapper; -using DocumentService.Word.Models; -using DocumentService.API.Models; - -namespace DocumentService.API.Helpers; - -public class AutoMappingProfile : Profile -{ - public AutoMappingProfile() - { - this.CreateMap(); - } -} diff --git a/DocumentService.API/Models/PdfGenerationRequestDTO.cs b/DocumentService.API/Models/PdfGenerationRequestDTO.cs deleted file mode 100644 index 8408128..0000000 --- a/DocumentService.API/Models/PdfGenerationRequestDTO.cs +++ /dev/null @@ -1,13 +0,0 @@ -using DocumentService.Pdf.Models; -using System.ComponentModel.DataAnnotations; - -namespace DocumentService.API.Models; - -public class PdfGenerationRequestDTO -{ - [Required(ErrorMessage = "Base64 string for PDF template is required")] - public string? Base64 { get; set; } - [Required(ErrorMessage = "Data to be modified in PDF is required")] - public DocumentData? DocumentData { get; set; } - public string? SerializedEjsDataJson { get; set; } -} diff --git a/DocumentService.API/Models/WordGenerationRequestDTO.cs b/DocumentService.API/Models/WordGenerationRequestDTO.cs deleted file mode 100644 index c2462a1..0000000 --- a/DocumentService.API/Models/WordGenerationRequestDTO.cs +++ /dev/null @@ -1,24 +0,0 @@ -using DocumentService.Word.Models; -using System.ComponentModel.DataAnnotations; - -namespace DocumentService.API.Models; - - -public class WordGenerationRequestDTO -{ - [Required(ErrorMessage = "Base64 string for Word template is required")] - public string? Base64 { get; set; } - [Required(ErrorMessage = "Data to be modified in Word file is required")] - public WordDocumentDataRequestDTO? DocumentData { get; set; } -} - -public class WordContentDataRequestDTO : ContentData -{ - public string? ImageExtension { get; set; } -} - -public class WordDocumentDataRequestDTO -{ - public List Placeholders { get; set; } - public List TablesData { get; set; } -} diff --git a/DocumentService/DocumentService.csproj b/DocumentService/DocumentService.csproj deleted file mode 100644 index 0bb92df..0000000 --- a/DocumentService/DocumentService.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - True - - - - - - - - - diff --git a/DocumentService/Pdf/Models/ContentMetaData.cs b/DocumentService/Pdf/Models/ContentMetaData.cs deleted file mode 100644 index 1f9cb3e..0000000 --- a/DocumentService/Pdf/Models/ContentMetaData.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace DocumentService.Pdf.Models -{ - public class ContentMetaData - { - public string Placeholder { get; set; } - public string Content { get; set; } - } -} - - diff --git a/DocumentService/Pdf/Models/DocumentData.cs b/DocumentService/Pdf/Models/DocumentData.cs deleted file mode 100644 index 9a93c7a..0000000 --- a/DocumentService/Pdf/Models/DocumentData.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace DocumentService.Pdf.Models -{ - public class DocumentData - { - public List Placeholders { get; set; } - } -} diff --git a/DocumentService/Pdf/PdfDocumentGenerator.cs b/DocumentService/Pdf/PdfDocumentGenerator.cs deleted file mode 100644 index a3cb308..0000000 --- a/DocumentService/Pdf/PdfDocumentGenerator.cs +++ /dev/null @@ -1,213 +0,0 @@ -using DocumentService.Pdf.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; - -namespace DocumentService.Pdf -{ - public class PdfDocumentGenerator - { - public static void GeneratePdf(string toolFolderAbsolutePath, string templatePath, List metaDataList, string outputFilePath, bool isEjsTemplate, string serializedEjsDataJson) - { - try - { - if (!File.Exists(templatePath)) - { - throw new Exception("The file path you provided is not valid."); - } - - if (isEjsTemplate) - { - // Validate if template in file path is an ejs file - if (Path.GetExtension(templatePath).ToLower() != ".ejs") - { - throw new Exception("Input template should be a valid EJS file"); - } - - // Convert ejs file to an equivalent html - templatePath = ConvertEjsToHTML(templatePath, outputFilePath, serializedEjsDataJson); - } - - // Modify html template with content data and generate pdf - string modifiedHtmlFilePath = ReplaceFileElementsWithMetaData(templatePath, metaDataList, outputFilePath); - ConvertHtmlToPdf(toolFolderAbsolutePath, modifiedHtmlFilePath, outputFilePath); - - if (isEjsTemplate) - { - // If input template was an ejs file, then the template path contains path to html converted from ejs - if (Path.GetExtension(templatePath).ToLower() == ".html") - { - // If template path contains path to converted html template then delete it - File.Delete(templatePath); - } - } - } - catch (Exception e) - { - throw e; - } - } - - private static string ReplaceFileElementsWithMetaData(string templatePath, List metaDataList, string outputFilePath) - { - string htmlContent = File.ReadAllText(templatePath); - - foreach (ContentMetaData metaData in metaDataList) - { - htmlContent = htmlContent.Replace($"{{{metaData.Placeholder}}}", metaData.Content); - } - - string directoryPath = Path.GetDirectoryName(outputFilePath); - string tempHtmlFilePath = Path.Combine(directoryPath, "Modified"); - string tempHtmlFile = Path.Combine(tempHtmlFilePath, "modifiedHtml.html"); - - if (!Directory.Exists(tempHtmlFilePath)) - { - Directory.CreateDirectory(tempHtmlFilePath); - } - - File.WriteAllText(tempHtmlFile, htmlContent); - return tempHtmlFile; - } - - private static void ConvertHtmlToPdf(string toolFolderAbsolutePath, string modifiedHtmlFilePath, string outputFilePath) - { - string wkHtmlToPdfPath = "cmd.exe"; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - wkHtmlToPdfPath = "wkhtmltopdf"; - } - - /* - * FIXME: Issue if tools file path has spaces in between - */ - string arguments = HtmlToPdfArgumentsBasedOnOS(toolFolderAbsolutePath, modifiedHtmlFilePath, outputFilePath); - - ProcessStartInfo psi = new ProcessStartInfo - { - FileName = wkHtmlToPdfPath, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using (Process process = new Process()) - { - process.StartInfo = psi; - process.Start(); - process.WaitForExit(); - string output = process.StandardOutput.ReadToEnd(); - string errors = process.StandardError.ReadToEnd(); - } - - File.Delete(modifiedHtmlFilePath); - } - - private static string ConvertEjsToHTML(string ejsFilePath, string outputFilePath, string ejsDataJson) - { - // Generate directory - string directoryPath = Path.GetDirectoryName(outputFilePath); - string tempDirectoryFilePath = Path.Combine(directoryPath, "Temp"); - - if (!Directory.Exists(tempDirectoryFilePath)) - { - Directory.CreateDirectory(tempDirectoryFilePath); - } - - // Generate file path to converted html template - string tempHtmlFilePath = Path.Combine(tempDirectoryFilePath, "htmlTemplate.html"); - - // If the ejs data json is invalid then throw exception - if (!string.IsNullOrWhiteSpace(ejsDataJson) && !IsValidJSON(ejsDataJson)) - { - throw new Exception("Received invalid JSON data for EJS template"); - } - - // Write json data string to json file - string ejsDataJsonFilePath = Path.Combine(tempDirectoryFilePath, "ejsData.json"); - File.WriteAllText(ejsDataJsonFilePath, ejsDataJson); - - string commandLine = "cmd.exe"; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - commandLine = "ejs"; - } - string arguments = EjsToHtmlArgumentsBasedOnOS(ejsFilePath, ejsDataJsonFilePath, tempHtmlFilePath); - - ProcessStartInfo psi = new ProcessStartInfo - { - FileName = commandLine, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using (Process process = new Process()) - { - process.StartInfo = psi; - process.Start(); - process.WaitForExit(); - string output = process.StandardOutput.ReadToEnd(); - string errors = process.StandardError.ReadToEnd(); - } - - // Delete json data file - File.Delete(ejsDataJsonFilePath); - - return tempHtmlFilePath; - } - - private static bool IsValidJSON(string json) - { - try - { - JToken.Parse(json); - return true; - } - catch (JsonReaderException) - { - return false; - } - } - - private static string HtmlToPdfArgumentsBasedOnOS(string toolFolderAbsolutePath, string modifiedHtmlFilePath, string outputFilePath) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return $"/C {toolFolderAbsolutePath} \"{modifiedHtmlFilePath}\" \"{outputFilePath}\""; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return $"{modifiedHtmlFilePath} {outputFilePath}"; - } - else - { - throw new Exception("Unknown operating system"); - } - } - - private static string EjsToHtmlArgumentsBasedOnOS(string ejsFilePath, string ejsDataJsonFilePath, string tempHtmlFilePath) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return $"/C ejs \"{ejsFilePath}\" -f \"{ejsDataJsonFilePath}\" -o \"{tempHtmlFilePath}\""; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return $"{ejsFilePath} -f {ejsDataJsonFilePath} -o {tempHtmlFilePath}"; - } - else - { - throw new Exception("Unknown operating system"); - } - } - } -} diff --git a/DocumentService/Word/Models/ContentData.cs b/DocumentService/Word/Models/ContentData.cs deleted file mode 100644 index c6f4ae2..0000000 --- a/DocumentService/Word/Models/ContentData.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace DocumentService.Word.Models -{ - - /// - /// Represents the data for a content placeholder in a Word document. - /// - public class ContentData - { - /// - /// Gets or sets the placeholder name. - /// - public string Placeholder { get; set; } - - /// - /// Gets or sets the content to replace the placeholder with. - /// - public string Content { get; set; } - - /// - /// Gets or sets the content type of the placeholder (text or image). - /// - public ContentType ContentType { get; set; } - - /// - /// Gets or sets the parent body of the placeholder (none or table). - /// - - public ParentBody ParentBody { get; set; } - } -} diff --git a/DocumentService/Word/Models/DocumentData.cs b/DocumentService/Word/Models/DocumentData.cs deleted file mode 100644 index 1ca80fe..0000000 --- a/DocumentService/Word/Models/DocumentData.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace DocumentService.Word.Models -{ - - /// - /// Represents the data for a Word document, including content placeholders and table data. - /// - public class DocumentData - { - /// - /// Gets or sets the list of content placeholders in the document. - /// - public List Placeholders { get; set; } - - /// - /// Gets or sets the list of table data in the document. - /// - - public List TablesData { get; set; } - } -} diff --git a/DocumentService/Word/Models/Enums.cs b/DocumentService/Word/Models/Enums.cs deleted file mode 100644 index dff7e01..0000000 --- a/DocumentService/Word/Models/Enums.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace DocumentService.Word.Models -{ - - /// - /// Represents the content type of a placeholder in a Word document. - /// - public enum ContentType - { - /// - /// The placeholder represents text content. - /// - Text = 0, - - /// - /// The placeholder represents an image. - /// - Image = 1 - } - - /// - /// Represents the parent body of a placeholder in a Word document. - /// - public enum ParentBody - { - /// - /// The placeholder does not have a parent body. - /// - None = 0, - - /// - /// The placeholder belongs to a table. - /// - - Table = 1 - } -} diff --git a/DocumentService/Word/Models/TableData.cs b/DocumentService/Word/Models/TableData.cs deleted file mode 100644 index f5e8a2a..0000000 --- a/DocumentService/Word/Models/TableData.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; - -namespace DocumentService.Word.Models -{ - - /// - /// Represents the data for a table in a Word document. - /// - public class TableData - { - /// - /// Gets or sets the position of the table in the document. - /// - public int TablePos { get; set; } - - /// - /// Gets or sets the list of dictionaries representing the data for each row in the table. - /// Each dictionary contains column header-value pairs. - /// - - public List> Data { get; set; } - } -} diff --git a/DocumentService/Word/WordDocumentGenerator.cs b/DocumentService/Word/WordDocumentGenerator.cs deleted file mode 100644 index 659d55e..0000000 --- a/DocumentService/Word/WordDocumentGenerator.cs +++ /dev/null @@ -1,310 +0,0 @@ -using DocumentFormat.OpenXml.Drawing; -using DocumentFormat.OpenXml.Drawing.Wordprocessing; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using DocumentService.Word.Models; -using NPOI.XWPF.UserModel; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text.RegularExpressions; - -namespace DocumentService.Word -{ - /// - /// Provides functionality to generate Word documents based on templates and data. - /// - public static class WordDocumentGenerator - { - /// - /// Generates a Word document based on a template, replaces placeholders with data, and saves it to the specified output file path. - /// - /// The file path of the template document. - /// The data to replace the placeholders in the template. - /// The file path to save the generated document. - public static void GenerateDocumentByTemplate(string templateFilePath, DocumentData documentData, string outputFilePath) - { - try - { - List contentData = documentData.Placeholders; - List tablesData = documentData.TablesData; - - // Creating dictionaries for each type of placeholders - Dictionary textPlaceholders = new Dictionary(); - Dictionary tableContentPlaceholders = new Dictionary(); - Dictionary imagePlaceholders = new Dictionary(); - - foreach (ContentData content in contentData) - { - if (content.ParentBody == ParentBody.None && content.ContentType == ContentType.Text) - { - string placeholder = "{" + content.Placeholder + "}"; - textPlaceholders.Add(placeholder, content.Content); - } - else if (content.ParentBody == ParentBody.None && content.ContentType == ContentType.Image) - { - string placeholder = content.Placeholder; - imagePlaceholders.Add(placeholder, content.Content); - } - else if (content.ParentBody == ParentBody.Table && content.ContentType == ContentType.Text) - { - string placeholder = "{" + content.Placeholder + "}"; - tableContentPlaceholders.Add(placeholder, content.Content); - } - } - - // Create document of the template - XWPFDocument document = GetXWPFDocument(templateFilePath); - - // For each element in the document - foreach (IBodyElement element in document.BodyElements) - { - if (element.ElementType == BodyElementType.PARAGRAPH) - { - // If element is a paragraph - XWPFParagraph paragraph = (XWPFParagraph)element; - - // If the paragraph is empty string or the placeholder regex does not match then continue - if (paragraph.ParagraphText == string.Empty || !new Regex(@"{[a-zA-Z]+}").IsMatch(paragraph.ParagraphText)) - { - continue; - } - - // Replace placeholders in paragraph with values - paragraph = ReplacePlaceholdersOnBody(paragraph, textPlaceholders); - } - else if (element.ElementType == BodyElementType.TABLE) - { - // If element is a table - XWPFTable table = (XWPFTable)element; - - // Replace placeholders in a table - table = ReplacePlaceholderOnTables(table, tableContentPlaceholders); - - // Populate the table with data if it is passed in tablesData list - foreach (TableData insertData in tablesData) - { - if (insertData.TablePos <= document.Tables.Count && table == document.Tables[insertData.TablePos - 1]) - { - table = PopulateTable(table, insertData); - } - } - } - } - - // Write the document to output file path and close the document - WriteDocument(document, outputFilePath); - document.Close(); - - /* - * Image Replacement is done after writing the document here, - * because for Text Replacement, NPOI package is being used - * and for Image Replacement, OpeXML package is used. - * Since both the packages have different execution method, so they are handled separately - */ - // Replace all the image placeholders in the output file - ReplaceImagePlaceholders(outputFilePath, outputFilePath, imagePlaceholders); - } - catch (Exception ex) - { - throw ex; - } - } - - /// - /// Retrieves an instance of XWPFDocument from the specified document file path. - /// - /// The file path of the Word document. - /// An instance of XWPFDocument representing the Word document. - private static XWPFDocument GetXWPFDocument(string docFilePath) - { - FileStream readStream = File.OpenRead(docFilePath); - XWPFDocument document = new XWPFDocument(readStream); - readStream.Close(); - return document; - } - - /// - /// Writes the XWPFDocument to the specified file path. - /// - /// The XWPFDocument to write. - /// The file path to save the document. - private static void WriteDocument(XWPFDocument document, string filePath) - { - using (FileStream writeStream = File.Create(filePath)) - { - document.Write(writeStream); - } - } - - /// - /// Replaces the text placeholders in a paragraph with the specified values. - /// - /// The XWPFParagraph containing the placeholders. - /// The dictionary of text placeholders and their corresponding values. - /// The updated XWPFParagraph. - private static XWPFParagraph ReplacePlaceholdersOnBody(XWPFParagraph paragraph, Dictionary textPlaceholders) - { - // Get a list of all placeholders in the current paragraph - List placeholdersTobeReplaced = Regex.Matches(paragraph.ParagraphText, @"{[a-zA-Z]+}") - .Cast() - .Select(s => s.Groups[0].Value).ToList(); - - // For each placeholder in paragraph - foreach (string placeholder in placeholdersTobeReplaced) - { - // Replace text placeholders in paragraph with values - if (textPlaceholders.ContainsKey(placeholder)) - { - paragraph.ReplaceText(placeholder, textPlaceholders[placeholder]); - } - - paragraph.SpacingAfter = 0; - } - - return paragraph; - } - - /// - /// Replaces the text placeholders in a table with the specified values. - /// - /// The XWPFTable containing the placeholders. - /// The dictionary of table content placeholders and their corresponding values. - /// The updated XWPFTable. - private static XWPFTable ReplacePlaceholderOnTables(XWPFTable table, Dictionary tableContentPlaceholders) - { - // Loop through each cell of the table - foreach (XWPFTableRow row in table.Rows) - { - foreach (XWPFTableCell cell in row.GetTableCells()) - { - foreach (XWPFParagraph paragraph in cell.Paragraphs) - { - // Get a list of all placeholders in the current cell - List placeholdersTobeReplaced = Regex.Matches(paragraph.ParagraphText, @"{[a-zA-Z]+}") - .Cast() - .Select(s => s.Groups[0].Value).ToList(); - - // For each placeholder in the cell - foreach (string placeholder in placeholdersTobeReplaced) - { - // replace the placeholder with its value - if (tableContentPlaceholders.ContainsKey(placeholder)) - { - paragraph.ReplaceText(placeholder, tableContentPlaceholders[placeholder]); - } - } - } - } - } - - return table; - } - - /// - /// Populates a table with the specified data. - /// - /// The XWPFTable to populate. - /// The data to populate the table. - /// The updated XWPFTable. - private static XWPFTable PopulateTable(XWPFTable table, TableData tableData) - { - // Get the header row - XWPFTableRow headerRow = table.GetRow(0); - - // Return if no header row found or if it does not have any column - if (headerRow == null || headerRow.GetTableCells() == null || headerRow.GetTableCells().Count <= 0) - { - return table; - } - - // For each row's data stored in table data - foreach (Dictionary rowData in tableData.Data) - { - // Create a new row and its columns - XWPFTableRow row = table.CreateRow(); - - // For each cell in row - for (int cellNumber = 0; cellNumber < row.GetTableCells().Count; cellNumber++) - { - XWPFTableCell cell = row.GetCell(cellNumber); - - // Get the column header of this cell - string columnHeader = headerRow.GetCell(cellNumber).GetText(); - - // Add the cell's value - if (rowData.ContainsKey(columnHeader)) - { - cell.SetText(rowData[columnHeader]); - } - } - } - - return table; - } - - /// - /// Replaces the image placeholders in the output file with the specified images. - /// - /// The input file path containing the image placeholders. - /// The output file path where the updated document will be saved. - /// The dictionary of image placeholders and their corresponding image paths. - private static void ReplaceImagePlaceholders(string inputFilePath, string outputFilePath, Dictionary imagePlaceholders) - { - byte[] docBytes = File.ReadAllBytes(inputFilePath); - - // Write document bytes to memory - MemoryStream memoryStream = new MemoryStream(); - memoryStream.Write(docBytes, 0, docBytes.Length); - - using (WordprocessingDocument wordDocument = WordprocessingDocument.Open(memoryStream, true)) - { - MainDocumentPart mainDocumentPart = wordDocument.MainDocumentPart; - - // Get a list of drawings (images) - IEnumerable drawings = mainDocumentPart.Document.Descendants().ToList(); - - /* - * FIXME: Look on how we can improve this loop operation. - */ - foreach (Drawing drawing in drawings) - { - DocProperties docProperty = drawing.Descendants().FirstOrDefault(); - - // If drawing / image name is present in imagePlaceholders dictionary, then replace image - if (docProperty != null && imagePlaceholders.ContainsKey(docProperty.Name)) - { - List drawingBlips = drawing.Descendants().ToList(); - - foreach (Blip blip in drawingBlips) - { - OpenXmlPart imagePart = wordDocument.MainDocumentPart.GetPartById(blip.Embed); - - using (BinaryWriter writer = new BinaryWriter(imagePart.GetStream())) - { - string imagePath = imagePlaceholders[docProperty.Name]; - - /* - * WebClient has been deprecated and we need to use HTTPClient. - * This involves the methods to be asynchronous. - */ - using (WebClient webClient = new WebClient()) - { - writer.Write(webClient.DownloadData(imagePath)); - } - } - } - } - } - } - - // Overwrite the output file - FileStream fileStream = new FileStream(outputFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite); - memoryStream.WriteTo(fileStream); - fileStream.Close(); - memoryStream.Close(); - } - } -} \ No newline at end of file diff --git a/OsmoDoc.API/Controllers/LoginController.cs b/OsmoDoc.API/Controllers/LoginController.cs new file mode 100644 index 0000000..6d6a4f8 --- /dev/null +++ b/OsmoDoc.API/Controllers/LoginController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using OsmoDoc.API.Models; +using OsmoDoc.API.Helpers; +using OsmoDoc.Services; + +namespace OsmoDoc.API.Controllers; + +[Route("api")] +[ApiController] +public class LoginController : ControllerBase +{ + private readonly IRedisTokenStoreService _tokenStoreService; + private readonly ILogger _logger; + + public LoginController(IRedisTokenStoreService tokenStoreService, ILogger logger) + { + this._tokenStoreService = tokenStoreService; + this._logger = logger; + } + + [HttpPost] + [Route("login")] + [AllowAnonymous] + public async Task> Login([FromBody] LoginRequestDTO loginRequest) + { + BaseResponse response = new BaseResponse(ResponseStatus.Fail); + try + { + string token = AuthenticationHelper.JwtTokenGenerator(loginRequest.Email); + await this._tokenStoreService.StoreTokenAsync(token, loginRequest.Email, this.HttpContext.RequestAborted); + + response.Status = ResponseStatus.Success; + response.AuthToken = token; + response.Message = "Token generated successfully"; + return this.Ok(response); + } + catch (Exception ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + } + + [HttpPost] + [Route("revoke")] + [Authorize] + public async Task> RevokeToken([FromBody] RevokeTokenRequestDTO request) + { + BaseResponse response = new BaseResponse(ResponseStatus.Fail); + try + { + await this._tokenStoreService.RevokeTokenAsync(request.Token, this.HttpContext.RequestAborted); + + response.Status = ResponseStatus.Success; + response.Message = "Token revoked"; + return this.Ok(response); + } + catch (Exception ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + } +} \ No newline at end of file diff --git a/DocumentService.API/Controllers/PdfController.cs b/OsmoDoc.API/Controllers/PdfController.cs similarity index 52% rename from DocumentService.API/Controllers/PdfController.cs rename to OsmoDoc.API/Controllers/PdfController.cs index 0413daa..a89035d 100644 --- a/DocumentService.API/Controllers/PdfController.cs +++ b/OsmoDoc.API/Controllers/PdfController.cs @@ -1,207 +1,288 @@ -using DocumentService.Pdf; -using DocumentService.API.Helpers; -using DocumentService.API.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Authorization; - -namespace DocumentService.API.Controllers; - -[Route("api")] -[ApiController] -public class PdfController : ControllerBase -{ - private readonly IConfiguration _configuration; - private readonly IWebHostEnvironment _hostingEnvironment; - private readonly ILogger _logger; - - public PdfController(IConfiguration configuration, IWebHostEnvironment hostingEnvironment, ILogger logger) - { - this._configuration = configuration; - this._hostingEnvironment = hostingEnvironment; - this._logger = logger; - } - - [HttpPost] - [Authorize] - [Route("pdf/GeneratePdfUsingHtml")] - public async Task> GeneratePdf(PdfGenerationRequestDTO request) - { - BaseResponse response = new BaseResponse(ResponseStatus.Fail); - - try - { - // Generate filepath to save base64 html template - string htmlTemplateFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:TEMP").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:INPUT").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:HTML").Value, - CommonMethodsHelper.GenerateRandomFileName("html") - ); - - CommonMethodsHelper.CreateDirectoryIfNotExists(htmlTemplateFilePath); - - // Save base64 html template to inputs directory - await Base64StringHelper.SaveBase64StringToFilePath(request.Base64, htmlTemplateFilePath, this._configuration); - - // Initialize tools and output filepaths - string htmlToPDfToolsFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("STATIC_FILE_PATHS:HTML_TO_PDF_TOOL").Value - ); - - string outputFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:TEMP").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:OUTPUT").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:PDF").Value, - CommonMethodsHelper.GenerateRandomFileName("pdf") - ); - - CommonMethodsHelper.CreateDirectoryIfNotExists(outputFilePath); - - // Generate and save pdf in output directory - PdfDocumentGenerator.GeneratePdf( - htmlToPDfToolsFilePath, - htmlTemplateFilePath, - request.DocumentData.Placeholders, - outputFilePath, - isEjsTemplate: false, - serializedEjsDataJson: null - ); - - // Convert pdf file in output directory to base64 string - string outputBase64String = await Base64StringHelper.ConvertFileToBase64String(outputFilePath); - - // Return response - response.Status = ResponseStatus.Success; - response.Base64 = outputBase64String; - response.Message = "PDF generated successfully"; - return this.Ok(response); - } - catch (BadHttpRequestException ex) - { - response.Status = ResponseStatus.Error; - response.Message = ex.Message; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.BadRequest(response); - } - catch (FormatException ex) - { - response.Status = ResponseStatus.Error; - response.Message = "Error converting base64 string to file"; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.BadRequest(response); - } - catch (FileNotFoundException ex) - { - response.Status = ResponseStatus.Error; - response.Message = "Unable to load file saved from base64 string"; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.StatusCode(StatusCodes.Status500InternalServerError, response); - } - catch (Exception ex) - { - response.Status = ResponseStatus.Error; - response.Message = ex.Message; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.StatusCode(StatusCodes.Status500InternalServerError, response); - } - } - - [HttpPost] - [Authorize] - [Route("pdf/GeneratePdfUsingEjs")] - public async Task> GeneratePdfUsingEjs(PdfGenerationRequestDTO request) - { - BaseResponse response = new BaseResponse(ResponseStatus.Fail); - - try - { - // Generate filepath to save base64 html template - string ejsTemplateFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:TEMP").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:INPUT").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:EJS").Value, - CommonMethodsHelper.GenerateRandomFileName("ejs") - ); - - CommonMethodsHelper.CreateDirectoryIfNotExists(ejsTemplateFilePath); - - // Save base64 html template to inputs directory - await Base64StringHelper.SaveBase64StringToFilePath(request.Base64, ejsTemplateFilePath, this._configuration); - - // Initialize tools and output filepaths - string ejsToPDfToolsFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("STATIC_FILE_PATHS:HTML_TO_PDF_TOOL").Value - ); - - string outputFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:TEMP").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:OUTPUT").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:PDF").Value, - CommonMethodsHelper.GenerateRandomFileName("pdf") - ); - - CommonMethodsHelper.CreateDirectoryIfNotExists(outputFilePath); - - // Generate and save pdf in output directory - PdfDocumentGenerator.GeneratePdf( - ejsToPDfToolsFilePath, - ejsTemplateFilePath, - request.DocumentData?.Placeholders, - outputFilePath, - isEjsTemplate: true, - serializedEjsDataJson: request.SerializedEjsDataJson - ); - - // Convert pdf file in output directory to base64 string - string outputBase64String = await Base64StringHelper.ConvertFileToBase64String(outputFilePath); - - // Return response - response.Status = ResponseStatus.Success; - response.Base64 = outputBase64String; - response.Message = "PDF generated successfully"; - return this.Ok(response); - } - catch (BadHttpRequestException ex) - { - response.Status = ResponseStatus.Error; - response.Message = ex.Message; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.BadRequest(response); - } - catch (FormatException ex) - { - response.Status = ResponseStatus.Error; - response.Message = "Error converting base64 string to file"; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.BadRequest(response); - } - catch (FileNotFoundException ex) - { - response.Status = ResponseStatus.Error; - response.Message = "Unable to load file saved from base64 string"; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.StatusCode(StatusCodes.Status500InternalServerError, response); - } - catch (Exception ex) - { - response.Status = ResponseStatus.Error; - response.Message = ex.Message; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.StatusCode(StatusCodes.Status500InternalServerError, response); - } - } -} +using OsmoDoc.Pdf; +using OsmoDoc.API.Helpers; +using OsmoDoc.API.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; + +namespace OsmoDoc.API.Controllers; + +[Route("api")] +[ApiController] +public class PdfController : ControllerBase +{ + private readonly IConfiguration _configuration; + private readonly IWebHostEnvironment _hostingEnvironment; + private readonly ILogger _logger; + + public PdfController(IConfiguration configuration, IWebHostEnvironment hostingEnvironment, ILogger logger) + { + this._configuration = configuration; + this._hostingEnvironment = hostingEnvironment; + this._logger = logger; + } + + [HttpPost] + [Authorize] + [Route("pdf/GeneratePdfUsingHtml")] + public async Task> GeneratePdf(PdfGenerationRequestDTO request) + { + BaseResponse response = new BaseResponse(ResponseStatus.Fail); + string? htmlTemplateFilePath = null; + string? outputFilePath = null; + bool cleanupResources = this._configuration.GetValue("CONFIG:CLEAN_RESOURCES_GENERATED_BY_BASE64_STRINGS", false); + + try + { + if (request == null) + { + throw new BadHttpRequestException("Request body cannot be null"); + } + + string tempPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:TEMP") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:TEMP is missing."); + string inputPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:INPUT") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:INPUT is missing."); + string htmlPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:HTML") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:HTML is missing."); + string outputPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:OUTPUT") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:OUTPUT is missing."); + string pdfPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:PDF") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:PDF is missing."); + + + // Generate filepath to save base64 html template + htmlTemplateFilePath = Path.Combine( + this._hostingEnvironment.WebRootPath, + tempPath, + inputPath, + htmlPath, + CommonMethodsHelper.GenerateRandomFileName("html") + ); + + CommonMethodsHelper.CreateDirectoryIfNotExists(htmlTemplateFilePath); + + // Save base64 html template to inputs directory + await Base64StringHelper.SaveBase64StringToFilePath(request.Base64, htmlTemplateFilePath, this._configuration); + + outputFilePath = Path.Combine( + this._hostingEnvironment.WebRootPath, + tempPath, + outputPath, + pdfPath, + CommonMethodsHelper.GenerateRandomFileName("pdf") + ); + + CommonMethodsHelper.CreateDirectoryIfNotExists(outputFilePath); + + // Generate and save pdf in output directory + await PdfDocumentGenerator.GeneratePdf( + htmlTemplateFilePath, + request.DocumentData.Placeholders, + outputFilePath, + isEjsTemplate: false, + serializedEjsDataJson: null + ); + + // Convert pdf file in output directory to base64 string + string outputBase64String = await Base64StringHelper.ConvertFileToBase64String(outputFilePath); + + // Return response + response.Status = ResponseStatus.Success; + response.Base64 = outputBase64String; + response.Message = "PDF generated successfully"; + return this.Ok(response); + } + catch (BadHttpRequestException ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.BadRequest(response); + } + catch (FormatException ex) + { + response.Status = ResponseStatus.Error; + response.Message = "Error converting base64 string to file"; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.BadRequest(response); + } + catch (FileNotFoundException ex) + { + response.Status = ResponseStatus.Error; + response.Message = "Unable to load file saved from base64 string"; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + catch (Exception ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + finally + { + if (cleanupResources) + { + if (htmlTemplateFilePath != null && System.IO.File.Exists(htmlTemplateFilePath)) + { + try + { + System.IO.File.Delete(htmlTemplateFilePath); + } + catch (Exception ex) + { + this._logger.LogError($"Error in deleting file at path {htmlTemplateFilePath}: {ex.Message}"); + } + } + if (outputFilePath != null && System.IO.File.Exists(outputFilePath)) + { + try + { + System.IO.File.Delete(outputFilePath); + } + catch (Exception ex) + { + this._logger.LogError($"Error in deleting file at path {outputFilePath}: {ex.Message}"); + } + } + } + } + } + + [HttpPost] + [Authorize] + [Route("pdf/GeneratePdfUsingEjs")] + public async Task> GeneratePdfUsingEjs(PdfGenerationRequestDTO request) + { + BaseResponse response = new BaseResponse(ResponseStatus.Fail); + string? ejsTemplateFilePath = null; + string? outputFilePath = null; + bool cleanupResources = this._configuration.GetValue("CONFIG:CLEAN_RESOURCES_GENERATED_BY_BASE64_STRINGS", false); + + try + { + if (request == null) + { + throw new BadHttpRequestException("Request body cannot be null"); + } + + string tempPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:TEMP") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:TEMP is missing."); + string inputPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:INPUT") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:INPUT is missing."); + string ejsPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:EJS") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:EJS is missing."); + string outputPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:OUTPUT") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:OUTPUT is missing."); + string pdfPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:PDF") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:PDF is missing."); + + // Generate filepath to save base64 html template + ejsTemplateFilePath = Path.Combine( + this._hostingEnvironment.WebRootPath, + tempPath, + inputPath, + ejsPath, + CommonMethodsHelper.GenerateRandomFileName("ejs") + ); + + CommonMethodsHelper.CreateDirectoryIfNotExists(ejsTemplateFilePath); + + // Save base64 html template to inputs directory + await Base64StringHelper.SaveBase64StringToFilePath(request.Base64, ejsTemplateFilePath, this._configuration); + + outputFilePath = Path.Combine( + this._hostingEnvironment.WebRootPath, + tempPath, + outputPath, + pdfPath, + CommonMethodsHelper.GenerateRandomFileName("pdf") + ); + + CommonMethodsHelper.CreateDirectoryIfNotExists(outputFilePath); + + // Generate and save pdf in output directory + await PdfDocumentGenerator.GeneratePdf( + ejsTemplateFilePath, + request.DocumentData.Placeholders, + outputFilePath, + isEjsTemplate: true, + serializedEjsDataJson: request.SerializedEjsDataJson + ); + + // Convert pdf file in output directory to base64 string + string outputBase64String = await Base64StringHelper.ConvertFileToBase64String(outputFilePath); + + // Return response + response.Status = ResponseStatus.Success; + response.Base64 = outputBase64String; + response.Message = "PDF generated successfully"; + return this.Ok(response); + } + catch (BadHttpRequestException ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.BadRequest(response); + } + catch (FormatException ex) + { + response.Status = ResponseStatus.Error; + response.Message = "Error converting base64 string to file"; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.BadRequest(response); + } + catch (FileNotFoundException ex) + { + response.Status = ResponseStatus.Error; + response.Message = "Unable to load file saved from base64 string"; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + catch (Exception ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + finally + { + if (cleanupResources) + { + if (ejsTemplateFilePath != null && System.IO.File.Exists(ejsTemplateFilePath)) + { + try + { + System.IO.File.Delete(ejsTemplateFilePath); + } + catch (Exception ex) + { + this._logger.LogError($"Error in deleting file at path {ejsTemplateFilePath}: {ex.Message}"); + } + } + if (outputFilePath != null && System.IO.File.Exists(outputFilePath)) + { + try + { + System.IO.File.Delete(outputFilePath); + } + catch (Exception ex) + { + this._logger.LogError($"Error in deleting file at path {outputFilePath}: {ex.Message}"); + } + } + } + } + } +} diff --git a/DocumentService.API/Controllers/WordController.cs b/OsmoDoc.API/Controllers/WordController.cs similarity index 55% rename from DocumentService.API/Controllers/WordController.cs rename to OsmoDoc.API/Controllers/WordController.cs index 33601a8..df9a1d2 100644 --- a/DocumentService.API/Controllers/WordController.cs +++ b/OsmoDoc.API/Controllers/WordController.cs @@ -1,156 +1,175 @@ -using AutoMapper; -using DocumentService.Word; -using DocumentService.Word.Models; -using DocumentService.API.Helpers; -using DocumentService.API.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Authorization; - -namespace DocumentService.API.Controllers; - -[Route("api")] -[ApiController] -public class WordController : ControllerBase -{ - private readonly IConfiguration _configuration; - private readonly IWebHostEnvironment _hostingEnvironment; - private readonly ILogger _logger; - private readonly IMapper _mapper; - - public WordController(IConfiguration configuration, IWebHostEnvironment hostingEnvironment, ILogger logger, IMapper mapper) - { - this._configuration = configuration; - this._hostingEnvironment = hostingEnvironment; - this._logger = logger; - this._mapper = mapper; - } - - [HttpPost] - [Authorize] - [Route("word/GenerateWordDocument")] - public async Task> GenerateWord(WordGenerationRequestDTO request) - { - BaseResponse response = new BaseResponse(ResponseStatus.Fail); - - try - { - // Generate filepath to save base64 docx template - string docxTemplateFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:TEMP").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:INPUT").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:WORD").Value, - CommonMethodsHelper.GenerateRandomFileName("docx") - ); - - CommonMethodsHelper.CreateDirectoryIfNotExists(docxTemplateFilePath); - - // Save docx template to inputs directory - await Base64StringHelper.SaveBase64StringToFilePath(request.Base64, docxTemplateFilePath, this._configuration); - - // Initialize output filepath - string outputFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:TEMP").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:OUTPUT").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:WORD").Value, - CommonMethodsHelper.GenerateRandomFileName("docx") - ); - - CommonMethodsHelper.CreateDirectoryIfNotExists(outputFilePath); - - // Handle image placeholder data in request - foreach (WordContentDataRequestDTO placeholder in request.DocumentData.Placeholders) - { - if (placeholder.ContentType == ContentType.Image) - { - if (string.IsNullOrWhiteSpace(placeholder.ImageExtension)) - { - throw new BadHttpRequestException("Image extension is required for image content data"); - } - - if (string.IsNullOrWhiteSpace(placeholder.Content)) - { - throw new BadHttpRequestException("Image content data is required"); - } - - // Remove '.' from image extension if present - placeholder.ImageExtension = placeholder.ImageExtension.Replace(".", string.Empty); - - // Generate a random image file name and its path - string imageFilePath = Path.Combine( - this._hostingEnvironment.WebRootPath, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:TEMP").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:INPUT").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:WORD").Value, - this._configuration.GetSection("TEMPORARY_FILE_PATHS:IMAGES").Value, - CommonMethodsHelper.GenerateRandomFileName(placeholder.ImageExtension) - ); - - CommonMethodsHelper.CreateDirectoryIfNotExists(imageFilePath); - - // Save image content base64 string to inputs directory - await Base64StringHelper.SaveBase64StringToFilePath(placeholder.Content, imageFilePath, this._configuration); - - // Replace placeholder content with image file path - placeholder.Content = imageFilePath; - } - } - - // Map document data in request to word library model class - DocumentData documentData = new DocumentData - { - Placeholders = this._mapper.Map>(request.DocumentData.Placeholders), - TablesData = request.DocumentData.TablesData - }; - - // Generate and save output docx in output directory - WordDocumentGenerator.GenerateDocumentByTemplate( - docxTemplateFilePath, - documentData, - outputFilePath - ); - - // Convert docx file in output directory to base64 string - string outputBase64String = await Base64StringHelper.ConvertFileToBase64String(outputFilePath); - - // Return response - response.Status = ResponseStatus.Success; - response.Base64 = outputBase64String; - response.Message = "Word document generated successfully"; - return this.Ok(response); - } - catch (BadHttpRequestException ex) - { - response.Status = ResponseStatus.Error; - response.Message = ex.Message; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.BadRequest(response); - } - catch (FormatException ex) - { - response.Status = ResponseStatus.Error; - response.Message = "Error converting base64 string to file"; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.BadRequest(response); - } - catch (FileNotFoundException ex) - { - response.Status = ResponseStatus.Error; - response.Message = "Unable to load file saved from base64 string"; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.StatusCode(StatusCodes.Status500InternalServerError, response); - } - catch (Exception ex) - { - response.Status = ResponseStatus.Error; - response.Message = ex.Message; - this._logger.LogError(ex.Message); - this._logger.LogError(ex.StackTrace); - return this.StatusCode(StatusCodes.Status500InternalServerError, response); - } - } -} +using AutoMapper; +using OsmoDoc.Word; +using OsmoDoc.Word.Models; +using OsmoDoc.API.Helpers; +using OsmoDoc.API.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; + +namespace OsmoDoc.API.Controllers; + +[Route("api")] +[ApiController] +public class WordController : ControllerBase +{ + private readonly IConfiguration _configuration; + private readonly IWebHostEnvironment _hostingEnvironment; + private readonly ILogger _logger; + private readonly IMapper _mapper; + + public WordController(IConfiguration configuration, IWebHostEnvironment hostingEnvironment, ILogger logger, IMapper mapper) + { + this._configuration = configuration; + this._hostingEnvironment = hostingEnvironment; + this._logger = logger; + this._mapper = mapper; + } + + [HttpPost] + [Authorize] + [Route("word/GenerateWordDocument")] + public async Task> GenerateWord(WordGenerationRequestDTO request) + { + BaseResponse response = new BaseResponse(ResponseStatus.Fail); + string? docxTemplateFilePath = null; + string? outputFilePath = null; + bool cleanupResources = this._configuration.GetValue("CONFIG:CLEAN_RESOURCES_GENERATED_BY_BASE64_STRINGS", false); + + try + { + if (request == null) + { + throw new BadHttpRequestException("Request body cannot be null"); + } + + if (request.DocumentData == null) + { + throw new BadHttpRequestException("Document data is required"); + } + + string tempPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:TEMP") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:TEMP is missing."); + string inputPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:INPUT") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:INPUT is missing."); + string wordPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:WORD") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:WORD is missing."); + string outputPath = this._configuration.GetValue("TEMPORARY_FILE_PATHS:OUTPUT") + ?? throw new InvalidOperationException("Configuration TEMPORARY_FILE_PATHS:OUTPUT is missing."); + + // Generate filepath to save base64 docx template + docxTemplateFilePath = Path.Combine( + this._hostingEnvironment.WebRootPath, + tempPath, + inputPath, + wordPath, + CommonMethodsHelper.GenerateRandomFileName("docx") + ); + + CommonMethodsHelper.CreateDirectoryIfNotExists(docxTemplateFilePath); + + // Save docx template to inputs directory + await Base64StringHelper.SaveBase64StringToFilePath(request.Base64, docxTemplateFilePath, this._configuration); + + // Initialize output filepath + outputFilePath = Path.Combine( + this._hostingEnvironment.WebRootPath, + tempPath, + outputPath, + wordPath, + CommonMethodsHelper.GenerateRandomFileName("docx") + ); + + CommonMethodsHelper.CreateDirectoryIfNotExists(outputFilePath); + + // Validate images data + if (request.DocumentData.ImagesData.Any(img => string.IsNullOrEmpty(img.Data)) == true) + { + throw new BadHttpRequestException("Invalid image data: Image content is required"); + } + + // Map document data in request to word library model class + DocumentData documentData = new DocumentData + { + Placeholders = this._mapper.Map>(request.DocumentData.Placeholders), + TablesData = request.DocumentData.TablesData, + Images = request.DocumentData.ImagesData + }; + + // Generate and save output docx in output directory + await WordDocumentGenerator.GenerateDocumentByTemplate( + docxTemplateFilePath, + documentData, + outputFilePath + ); + + // Convert docx file in output directory to base64 string + string outputBase64String = await Base64StringHelper.ConvertFileToBase64String(outputFilePath); + + // Return response + response.Status = ResponseStatus.Success; + response.Base64 = outputBase64String; + response.Message = "Word document generated successfully"; + return this.Ok(response); + } + catch (BadHttpRequestException ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.BadRequest(response); + } + catch (FormatException ex) + { + response.Status = ResponseStatus.Error; + response.Message = "Error converting base64 string to file"; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.BadRequest(response); + } + catch (FileNotFoundException ex) + { + response.Status = ResponseStatus.Error; + response.Message = "Unable to load file saved from base64 string"; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + catch (Exception ex) + { + response.Status = ResponseStatus.Error; + response.Message = ex.Message; + this._logger.LogError(ex.Message); + this._logger.LogError(ex.StackTrace); + return this.StatusCode(StatusCodes.Status500InternalServerError, response); + } + finally + { + if (cleanupResources) + { + if (docxTemplateFilePath != null && System.IO.File.Exists(docxTemplateFilePath)) + { + try + { + System.IO.File.Delete(docxTemplateFilePath); + } + catch (Exception ex) + { + this._logger.LogError($"Error in deleting file at path {docxTemplateFilePath}: {ex.Message}"); + } + } + if (outputFilePath != null && System.IO.File.Exists(outputFilePath)) + { + try + { + System.IO.File.Delete(outputFilePath); + } + catch (Exception ex) + { + this._logger.LogError($"Error in deleting file at path {outputFilePath}: {ex.Message}"); + } + } + } + } + } +} diff --git a/DocumentService.API/DotEnv.cs b/OsmoDoc.API/DotEnv.cs similarity index 60% rename from DocumentService.API/DotEnv.cs rename to OsmoDoc.API/DotEnv.cs index 2b5b4ce..a574d2f 100644 --- a/DocumentService.API/DotEnv.cs +++ b/OsmoDoc.API/DotEnv.cs @@ -1,35 +1,48 @@ -namespace DocumentService.API; - -public static class DotEnv -{ - public static void Load(string filePath) - { - if (!File.Exists(filePath)) - { - return; - } - - foreach (string line in File.ReadAllLines(filePath)) - { - // Check if the line contains '=' - int equalsIndex = line.IndexOf('='); - if (equalsIndex == -1) - { - continue; // Skip lines without '=' - } - - string key = line.Substring(0, equalsIndex).Trim(); - string value = line.Substring(equalsIndex + 1).Trim(); - - // Check if the value starts and ends with double quotation marks - if (value.StartsWith("\"") && value.EndsWith("\"")) - { - // Remove the double quotation marks - value = value[1..^1]; - } - - Environment.SetEnvironmentVariable(key, value); - } - } - -} +namespace OsmoDoc.API; + +public static class DotEnv +{ + public static void LoadEnvFile(string fileName = ".env") + { + DirectoryInfo? dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + + while (dir != null) + { + string envPath = Path.Combine(dir.FullName, fileName); + if (File.Exists(envPath)) + { + Load(envPath); + return; + } + + dir = dir.Parent; + } + + throw new FileNotFoundException($"{fileName} file not found"); + } + + public static void Load(string filePath) + { + foreach (string line in File.ReadAllLines(filePath)) + { + // Check if the line contains '=' + int equalsIndex = line.IndexOf('='); + if (equalsIndex == -1) + { + continue; // Skip lines without '=' + } + + string key = line.Substring(0, equalsIndex).Trim(); + string value = line.Substring(equalsIndex + 1).Trim(); + + // Check if the value starts and ends with double quotation marks + if (value.StartsWith("\"") && value.EndsWith("\"")) + { + // Remove the double quotation marks + value = value[1..^1]; + } + + Environment.SetEnvironmentVariable(key, value); + } + } +} diff --git a/DocumentService.API/Helpers/AuthenticationHelper.cs b/OsmoDoc.API/Helpers/AuthenticationHelper.cs similarity index 94% rename from DocumentService.API/Helpers/AuthenticationHelper.cs rename to OsmoDoc.API/Helpers/AuthenticationHelper.cs index ed0dee9..04dc325 100644 --- a/DocumentService.API/Helpers/AuthenticationHelper.cs +++ b/OsmoDoc.API/Helpers/AuthenticationHelper.cs @@ -1,28 +1,28 @@ -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Text; -using System.Security.Claims; - -namespace DocumentService.API.Helpers; - -public class AuthenticationHelper -{ - // Function to generate non expiry jwt token based on email - public static string JwtTokenGenerator(string LoginEmail) - { - SymmetricSecurityKey secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( - Environment.GetEnvironmentVariable("JWT_KEY") ?? throw new InvalidOperationException("No JWT key specified") - )); - SigningCredentials signinCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256); - - JwtSecurityToken tokenOptions = new JwtSecurityToken( - claims: new List() - { - new(ClaimTypes.Email, LoginEmail), - }, - signingCredentials: signinCredentials - ); - - return new JwtSecurityTokenHandler().WriteToken(tokenOptions); - } -} +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using System.Security.Claims; + +namespace OsmoDoc.API.Helpers; + +public class AuthenticationHelper +{ + // Function to generate non expiry jwt token based on email + public static string JwtTokenGenerator(string LoginEmail) + { + SymmetricSecurityKey secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( + Environment.GetEnvironmentVariable("JWT_KEY") ?? throw new InvalidOperationException("No JWT key specified") + )); + SigningCredentials signinCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256); + + JwtSecurityToken tokenOptions = new JwtSecurityToken( + claims: new List() + { + new(ClaimTypes.Email, LoginEmail), + }, + signingCredentials: signinCredentials + ); + + return new JwtSecurityTokenHandler().WriteToken(tokenOptions); + } +} diff --git a/OsmoDoc.API/Helpers/AutoMappingProfile.cs b/OsmoDoc.API/Helpers/AutoMappingProfile.cs new file mode 100644 index 0000000..8869305 --- /dev/null +++ b/OsmoDoc.API/Helpers/AutoMappingProfile.cs @@ -0,0 +1,12 @@ +using AutoMapper; +using OsmoDoc.Word.Models; +using OsmoDoc.API.Models; + +namespace OsmoDoc.API.Helpers; + +public class AutoMappingProfile : Profile +{ + public AutoMappingProfile() + { + } +} diff --git a/DocumentService.API/Helpers/Base64StringHelper.cs b/OsmoDoc.API/Helpers/Base64StringHelper.cs similarity index 93% rename from DocumentService.API/Helpers/Base64StringHelper.cs rename to OsmoDoc.API/Helpers/Base64StringHelper.cs index 9984908..df48427 100644 --- a/DocumentService.API/Helpers/Base64StringHelper.cs +++ b/OsmoDoc.API/Helpers/Base64StringHelper.cs @@ -1,31 +1,31 @@ -namespace DocumentService.API.Helpers; - -public static class Base64StringHelper -{ - public static async Task SaveBase64StringToFilePath(string base64String, string filePath, IConfiguration configuration) - { - byte[] data = Convert.FromBase64String(base64String); - - long uploadFileSizeLimitBytes = Convert.ToInt64(configuration.GetSection("CONFIG:UPLOAD_FILE_SIZE_LIMIT_BYTES").Value); - - if (data.LongLength > uploadFileSizeLimitBytes) - { - throw new BadHttpRequestException("Uploaded file is too large"); - } - - await File.WriteAllBytesAsync(filePath, data); - } - - public static async Task ConvertFileToBase64String(string filePath) - { - if (File.Exists(filePath)) - { - byte[] fileData = await File.ReadAllBytesAsync(filePath); - return Convert.ToBase64String(fileData); - } - else - { - throw new FileNotFoundException("The file does not exist: " + filePath); - } - } -} +namespace OsmoDoc.API.Helpers; + +public static class Base64StringHelper +{ + public static async Task SaveBase64StringToFilePath(string base64String, string filePath, IConfiguration configuration) + { + byte[] data = Convert.FromBase64String(base64String); + + long uploadFileSizeLimitBytes = Convert.ToInt64(configuration.GetSection("CONFIG:UPLOAD_FILE_SIZE_LIMIT_BYTES").Value); + + if (data.LongLength > uploadFileSizeLimitBytes) + { + throw new BadHttpRequestException("Uploaded file is too large"); + } + + await File.WriteAllBytesAsync(filePath, data); + } + + public static async Task ConvertFileToBase64String(string filePath) + { + if (File.Exists(filePath)) + { + byte[] fileData = await File.ReadAllBytesAsync(filePath); + return Convert.ToBase64String(fileData); + } + else + { + throw new FileNotFoundException("The file does not exist: " + filePath); + } + } +} diff --git a/DocumentService.API/Helpers/CommonMethodsHelper.cs b/OsmoDoc.API/Helpers/CommonMethodsHelper.cs similarity index 70% rename from DocumentService.API/Helpers/CommonMethodsHelper.cs rename to OsmoDoc.API/Helpers/CommonMethodsHelper.cs index dc9006d..8c729bd 100644 --- a/DocumentService.API/Helpers/CommonMethodsHelper.cs +++ b/OsmoDoc.API/Helpers/CommonMethodsHelper.cs @@ -1,26 +1,33 @@ -namespace DocumentService.API.Helpers; - -public static class CommonMethodsHelper -{ - public static void CreateDirectoryIfNotExists(string filePath) - { - // Get directory name of the file - // If path is a file name only, directory name will be an empty string - string directoryName = Path.GetDirectoryName(filePath); - - if (!string.IsNullOrWhiteSpace(directoryName)) - { - if (!Directory.Exists(directoryName)) - { - // Create all directories on the path that don't already exist - Directory.CreateDirectory(directoryName); - } - } - } - - public static string GenerateRandomFileName(string fileExtension) - { - string randomFileName = Path.GetRandomFileName().Replace(".", string.Empty); - return $"{randomFileName}-{Guid.NewGuid()}.{fileExtension}"; - } -} +using System.IO; + +namespace OsmoDoc.API.Helpers; + +public static class CommonMethodsHelper +{ + public static void CreateDirectoryIfNotExists(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("File path cannot be null or empty.", nameof(filePath)); + } + + // Get directory name of the file + // If path is a file name only, directory name will be an empty string + string? directoryName = Path.GetDirectoryName(filePath); + + if (!string.IsNullOrWhiteSpace(directoryName)) + { + if (!Directory.Exists(directoryName)) + { + // Create all directories on the path that don't already exist + Directory.CreateDirectory(directoryName); + } + } + } + + public static string GenerateRandomFileName(string fileExtension) + { + string randomFileName = Path.GetRandomFileName().Replace(".", string.Empty); + return $"{randomFileName}-{Guid.NewGuid()}.{fileExtension}"; + } +} diff --git a/DocumentService.API/Models/BaseResponse.cs b/OsmoDoc.API/Models/BaseResponse.cs similarity index 51% rename from DocumentService.API/Models/BaseResponse.cs rename to OsmoDoc.API/Models/BaseResponse.cs index 0422bbe..fb72663 100644 --- a/DocumentService.API/Models/BaseResponse.cs +++ b/OsmoDoc.API/Models/BaseResponse.cs @@ -1,33 +1,38 @@ -using Microsoft.AspNetCore.Mvc; - -namespace DocumentService.API.Models; - -public enum ResponseStatus -{ - Success, - Fail, - Error -} - -public class BaseResponse -{ - public BaseResponse(ResponseStatus status) => this.Status = status; - public ResponseStatus? Status { get; set; } - public string? Base64 { get; set; } - public string? AuthToken { get; set; } - public string? Message { get; set; } - public string? StackTrace { get; set; } -} - -public class ModelValidationBadRequest -{ - public static BadRequestObjectResult ModelValidationErrorResponse(ActionContext actionContext) - { - return new BadRequestObjectResult(actionContext.ModelState - .Where(modelError => modelError.Value.Errors.Any()) - .Select(modelError => new BaseResponse(ResponseStatus.Error) - { - Message = modelError.Value.Errors.FirstOrDefault().ErrorMessage - }).FirstOrDefault()); - } -} +using Microsoft.AspNetCore.Mvc; + +namespace OsmoDoc.API.Models; + +public enum ResponseStatus +{ + Success, + Fail, + Error +} + +public class BaseResponse +{ + public BaseResponse(ResponseStatus status) => this.Status = status; + public ResponseStatus? Status { get; set; } + public string? Base64 { get; set; } + public string? AuthToken { get; set; } + public string? Message { get; set; } + public string? StackTrace { get; set; } +} + +public class ModelValidationBadRequest +{ + public static BadRequestObjectResult ModelValidationErrorResponse(ActionContext actionContext) + { + string? firstError = actionContext.ModelState + .Where(ms => ms.Value != null && ms.Value.Errors.Any()) + .Select(ms => ms.Value!.Errors.FirstOrDefault()?.ErrorMessage) + .FirstOrDefault(msg => !string.IsNullOrWhiteSpace(msg)); + + BaseResponse response = new BaseResponse(ResponseStatus.Error) + { + Message = firstError ?? "Validation failed" + }; + + return new BadRequestObjectResult(response); + } +} diff --git a/OsmoDoc.API/Models/LoginRequestDTO.cs b/OsmoDoc.API/Models/LoginRequestDTO.cs new file mode 100644 index 0000000..0afbba8 --- /dev/null +++ b/OsmoDoc.API/Models/LoginRequestDTO.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace OsmoDoc.API.Models; + +public class LoginRequestDTO +{ + [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Invalid email format")] + public string Email { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/OsmoDoc.API/Models/PdfGenerationRequestDTO.cs b/OsmoDoc.API/Models/PdfGenerationRequestDTO.cs new file mode 100644 index 0000000..52f8fc1 --- /dev/null +++ b/OsmoDoc.API/Models/PdfGenerationRequestDTO.cs @@ -0,0 +1,12 @@ +using OsmoDoc.Pdf.Models; +using System.ComponentModel.DataAnnotations; + +namespace OsmoDoc.API.Models; + +public class PdfGenerationRequestDTO +{ + [Required(ErrorMessage = "Base64 string for PDF template is required")] + public required string Base64 { get; set; } + public DocumentData DocumentData { get; set; } = new(); + public string? SerializedEjsDataJson { get; set; } +} diff --git a/OsmoDoc.API/Models/RevokeTokenRequestDTO.cs b/OsmoDoc.API/Models/RevokeTokenRequestDTO.cs new file mode 100644 index 0000000..d01a328 --- /dev/null +++ b/OsmoDoc.API/Models/RevokeTokenRequestDTO.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace OsmoDoc.API.Models; + +public class RevokeTokenRequestDTO +{ + [Required(ErrorMessage = "Token is required")] + public string Token { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/OsmoDoc.API/Models/WordGenerationRequestDTO.cs b/OsmoDoc.API/Models/WordGenerationRequestDTO.cs new file mode 100644 index 0000000..89b9d2f --- /dev/null +++ b/OsmoDoc.API/Models/WordGenerationRequestDTO.cs @@ -0,0 +1,20 @@ +using OsmoDoc.Word.Models; +using System.ComponentModel.DataAnnotations; + +namespace OsmoDoc.API.Models; + + +public class WordGenerationRequestDTO +{ + [Required(ErrorMessage = "Base64 string for Word template is required")] + public required string Base64 { get; set; } + [Required(ErrorMessage = "Data to be modified in Word file is required")] + public WordDocumentDataRequestDTO DocumentData { get; set; } = new(); +} + +public class WordDocumentDataRequestDTO +{ + public List Placeholders { get; set; } = new List(); + public List TablesData { get; set; } = new List(); + public List ImagesData { get; set; } = new List(); +} diff --git a/DocumentService.API/DocumentService.API.csproj b/OsmoDoc.API/OsmoDoc.API.csproj similarity index 83% rename from DocumentService.API/DocumentService.API.csproj rename to OsmoDoc.API/OsmoDoc.API.csproj index bcd5563..a36d4ff 100644 --- a/DocumentService.API/DocumentService.API.csproj +++ b/OsmoDoc.API/OsmoDoc.API.csproj @@ -8,10 +8,11 @@ + - + \ No newline at end of file diff --git a/DocumentService.API/DocumentService.API.sln b/OsmoDoc.API/OsmoDoc.API.sln similarity index 86% rename from DocumentService.API/DocumentService.API.sln rename to OsmoDoc.API/OsmoDoc.API.sln index ee22b37..94f94c8 100644 --- a/DocumentService.API/DocumentService.API.sln +++ b/OsmoDoc.API/OsmoDoc.API.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.7.34031.279 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentService.API", "DocumentService.API.csproj", "{A99B82C1-0758-4FDA-8D3B-5F11E99896F1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OsmoDoc.API", "OsmoDoc.API.csproj", "{A99B82C1-0758-4FDA-8D3B-5F11E99896F1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/DocumentService.API/Program.cs b/OsmoDoc.API/Program.cs similarity index 65% rename from DocumentService.API/Program.cs rename to OsmoDoc.API/Program.cs index 728b1ac..798f6b2 100644 --- a/DocumentService.API/Program.cs +++ b/OsmoDoc.API/Program.cs @@ -1,137 +1,176 @@ -using DocumentService.API.Models; -using Microsoft.AspNetCore.Mvc; -using Serilog.Events; -using Serilog; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.OpenApi.Models; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; -using System.Text; -using Swashbuckle.AspNetCore.Filters; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -// Controller Services -builder.Services.AddControllers(options => options.Filters.Add(new ProducesAttribute("application/json"))) - .AddJsonOptions(options => - { - options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); - -// Load .env file -string root = Directory.GetCurrentDirectory(); -string dotenv = Path.GetFullPath(Path.Combine(root, "..", ".env")); -DocumentService.API.DotEnv.Load(dotenv); - -// Configure request size limit -long requestBodySizeLimitBytes = Convert.ToInt64(builder.Configuration.GetSection("CONFIG:REQUEST_BODY_SIZE_LIMIT_BYTES").Value); - -// Configure request size for Kestrel server - ASP.NET Core project templates use Kestrel by default when not hosted with IIS -builder.Services.Configure(options => -{ - options.Limits.MaxRequestBodySize = requestBodySizeLimitBytes; -}); - -// Configure request size for IIS server -builder.Services.Configure(options => -{ - options.MaxRequestBodySize = requestBodySizeLimitBytes; -}); - -// AutoMapper Services -builder.Services.AddAutoMapper(typeof(Program)); - -// Swagger UI Services -builder.Services.AddSwaggerGen(options => -{ - options.SwaggerDoc("v1", new OpenApiInfo { Title = "DocumentService API", Version = "v1" }); - - options.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme - { - Name = "Authorization", - Description = "Standard Authorization header using the Bearer Scheme (\"bearer {token}\")", - In = ParameterLocation.Header, - Type = SecuritySchemeType.ApiKey - }); - - options.OperationFilter(); -}); - -// Authentication -builder.Services.AddAuthentication(options => -{ - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; -}) -.AddJwtBearer(options => -{ - string JWT_KEY = Environment.GetEnvironmentVariable("JWT_KEY") ?? throw new InvalidOperationException("No JWT key specified"); - - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JWT_KEY)), - ValidateIssuer = false, - ValidateAudience = false, - // Following code is to allow us to custom handle expiry - // Here check expiry as nullable - ClockSkew = TimeSpan.Zero, - ValidateLifetime = true, - LifetimeValidator = (DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) => - { - // Clone the validation parameters, and remove the defult lifetime validator - TokenValidationParameters clonedParameters = validationParameters.Clone(); - clonedParameters.LifetimeValidator = null; - - // If token expiry time is not null, then validate lifetime with skewed clock - if (expires != null) - { - Validators.ValidateLifetime(notBefore, expires, securityToken, clonedParameters); - } - - return true; - } - }; -}); - -// Configure Error Response from Model Validations -builder.Services.AddMvc().ConfigureApiBehaviorOptions(options => -{ - options.InvalidModelStateResponseFactory = actionContext => - { - return ModelValidationBadRequest.ModelValidationErrorResponse(actionContext); - }; -}); - -// Logging service Serilogs -builder.Logging.AddSerilog(); -Log.Logger = new LoggerConfiguration() - .WriteTo.File( - path: "wwwroot/logs/log-.txt", - outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}{NewLine}{NewLine}", - rollingInterval: RollingInterval.Day, - restrictedToMinimumLevel: LogEventLevel.Information - ).CreateLogger(); - -WebApplication app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseStaticFiles(); - -app.UseHttpsRedirection(); - -app.UseAuthentication(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); +using Microsoft.AspNetCore.Mvc; +using Serilog.Events; +using Serilog; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.OpenApi.Models; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using Swashbuckle.AspNetCore.Filters; +using OsmoDoc.Pdf; +using StackExchange.Redis; +using OsmoDoc.API.Models; +using OsmoDoc.Services; +using System.IdentityModel.Tokens.Jwt; +using System.Runtime.InteropServices; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Controller Services +builder.Services.AddControllers(options => options.Filters.Add(new ProducesAttribute("application/json"))) + .AddJsonOptions(options => + { + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + +// Load .env file +OsmoDoc.API.DotEnv.LoadEnvFile(); + +// Initialize PDF tool path once at startup (on Windows) +if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) +{ + OsmoDocPdfConfig.WkhtmltopdfPath = Path.Combine( + builder.Environment.WebRootPath, + builder.Configuration.GetSection("STATIC_FILE_PATHS:HTML_TO_PDF_TOOL").Value! + ); +} + +// Register REDIS service +builder.Services.AddSingleton( + ConnectionMultiplexer.Connect(Environment.GetEnvironmentVariable("REDIS_URL") ?? throw new Exception("No REDIS URL specified")) +); +builder.Services.AddScoped(); + +// Configure request size limit (50 MB default) +long requestBodySizeLimitBytes = builder.Configuration.GetValue("CONFIG:REQUEST_BODY_SIZE_LIMIT_BYTES", 50 * 1024 * 1024); + +// Configure request size for Kestrel server - ASP.NET Core project templates use Kestrel by default when not hosted with IIS +builder.Services.Configure(options => +{ + options.Limits.MaxRequestBodySize = requestBodySizeLimitBytes; +}); + +// Configure request size for IIS server +builder.Services.Configure(options => +{ + options.MaxRequestBodySize = requestBodySizeLimitBytes; +}); + +// AutoMapper Services +builder.Services.AddAutoMapper(typeof(Program)); + +// Swagger UI Services +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo { Title = "OsmoDoc API", Version = "v1" }); + + options.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme + { + Name = "Authorization", + Description = "Standard Authorization header using the Bearer Scheme (\"bearer {token}\")", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey + }); + + options.OperationFilter(); +}); + +// Authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + string JWT_KEY = Environment.GetEnvironmentVariable("JWT_KEY") ?? throw new InvalidOperationException("No JWT key specified"); + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JWT_KEY)), + ValidateIssuer = false, + ValidateAudience = false, + // Following code is to allow us to custom handle expiry + // Here check expiry as nullable + ClockSkew = TimeSpan.Zero, + ValidateLifetime = true, + LifetimeValidator = (DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) => + { + // Clone the validation parameters, and remove the defult lifetime validator + TokenValidationParameters clonedParameters = validationParameters.Clone(); + clonedParameters.LifetimeValidator = null; + + // If token expiry time is not null, then validate lifetime with skewed clock + if (expires != null) + { + Validators.ValidateLifetime(notBefore, expires, securityToken, clonedParameters); + } + + return true; + } + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + IRedisTokenStoreService tokenStore = context.HttpContext.RequestServices.GetRequiredService(); + JwtSecurityToken? token = context.SecurityToken as JwtSecurityToken; + string tokenString = string.Empty; + + if (context.Request.Headers.TryGetValue("Authorization", out Microsoft.Extensions.Primitives.StringValues authHeader) && + authHeader.ToString().StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + tokenString = authHeader.ToString().Substring("Bearer ".Length).Trim(); + } + + if (!await tokenStore.IsTokenValidAsync(tokenString, context.HttpContext.RequestAborted)) + { + context.Fail("Token has been revoked."); + } + } + }; +}); + +// Configure Error Response from Model Validations +builder.Services.AddMvc().ConfigureApiBehaviorOptions(options => +{ + options.InvalidModelStateResponseFactory = actionContext => + { + return ModelValidationBadRequest.ModelValidationErrorResponse(actionContext); + }; +}); + +// Logging service Serilogs +builder.Logging.AddSerilog(); +Log.Logger = new LoggerConfiguration() + .WriteTo.File( + path: "wwwroot/logs/log-.txt", + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}{NewLine}{NewLine}", + rollingInterval: RollingInterval.Day, + restrictedToMinimumLevel: LogEventLevel.Information + ).CreateLogger(); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment() || app.Environment.IsProduction()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseStaticFiles(); + +app.UseHttpsRedirection(); + +app.UseAuthentication(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/DocumentService.API/Properties/launchSettings.json b/OsmoDoc.API/Properties/launchSettings.json similarity index 100% rename from DocumentService.API/Properties/launchSettings.json rename to OsmoDoc.API/Properties/launchSettings.json diff --git a/DocumentService.API/appsettings.Development.json b/OsmoDoc.API/appsettings.Development.json similarity index 100% rename from DocumentService.API/appsettings.Development.json rename to OsmoDoc.API/appsettings.Development.json diff --git a/DocumentService.API/appsettings.json b/OsmoDoc.API/appsettings.json similarity index 87% rename from DocumentService.API/appsettings.json rename to OsmoDoc.API/appsettings.json index 90dfbb6..a76df53 100644 --- a/DocumentService.API/appsettings.json +++ b/OsmoDoc.API/appsettings.json @@ -26,7 +26,8 @@ }, "CONFIG": { "REQUEST_BODY_SIZE_LIMIT_BYTES": 52428800, - "UPLOAD_FILE_SIZE_LIMIT_BYTES": 5242880 + "UPLOAD_FILE_SIZE_LIMIT_BYTES": 5242880, + "CLEAN_RESOURCES_GENERATED_BY_BASE64_STRINGS": false }, "AllowedHosts": "*" } diff --git a/DocumentService.API/wwwroot/Tools/wkhtmltopdf.exe b/OsmoDoc.API/wwwroot/Tools/wkhtmltopdf.exe similarity index 100% rename from DocumentService.API/wwwroot/Tools/wkhtmltopdf.exe rename to OsmoDoc.API/wwwroot/Tools/wkhtmltopdf.exe diff --git a/DocumentService.sln b/OsmoDoc.sln similarity index 80% rename from DocumentService.sln rename to OsmoDoc.sln index 2a3de81..d4c665f 100644 --- a/DocumentService.sln +++ b/OsmoDoc.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DocumentService", "DocumentService\DocumentService.csproj", "{AC14A26A-220C-487E-9D7B-BB0548D86318}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OsmoDoc", "OsmoDoc\OsmoDoc.csproj", "{AC14A26A-220C-487E-9D7B-BB0548D86318}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DocumentService.API", "DocumentService.API\DocumentService.API.csproj", "{2D2738C1-032B-441A-9298-1722A4915BD4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OsmoDoc.API", "OsmoDoc.API\OsmoDoc.API.csproj", "{2D2738C1-032B-441A-9298-1722A4915BD4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/OsmoDoc/OsmoDoc.csproj b/OsmoDoc/OsmoDoc.csproj new file mode 100644 index 0000000..26c0182 --- /dev/null +++ b/OsmoDoc/OsmoDoc.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + True + enable + + OsmoDoc + 1.0.0 + Osmosys Software Solutions + Osmosys Software Solutions + A library for generating PDF documents from HTML and EJS templates using wkhtmltopdf. + https://github.com/OsmosysSoftware/osmodoc + pdf;html;ejs;wkhtmltopdf;document generation + MIT + https://github.com/OsmosysSoftware/osmodoc + git + + + + + + + + + + diff --git a/OsmoDoc/Pdf/Models/ContentMetaData.cs b/OsmoDoc/Pdf/Models/ContentMetaData.cs new file mode 100644 index 0000000..c7fe88f --- /dev/null +++ b/OsmoDoc/Pdf/Models/ContentMetaData.cs @@ -0,0 +1,9 @@ +namespace OsmoDoc.Pdf.Models; + +public class ContentMetaData +{ + public string Placeholder { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; +} + + diff --git a/OsmoDoc/Pdf/Models/DocumentData.cs b/OsmoDoc/Pdf/Models/DocumentData.cs new file mode 100644 index 0000000..3527fc4 --- /dev/null +++ b/OsmoDoc/Pdf/Models/DocumentData.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace OsmoDoc.Pdf.Models; + +public class DocumentData +{ + public List Placeholders { get; set; } = new List(); +} diff --git a/OsmoDoc/Pdf/Models/OsmoDocPdfConfig.cs b/OsmoDoc/Pdf/Models/OsmoDocPdfConfig.cs new file mode 100644 index 0000000..e401677 --- /dev/null +++ b/OsmoDoc/Pdf/Models/OsmoDocPdfConfig.cs @@ -0,0 +1,7 @@ +namespace OsmoDoc.Pdf; + +public static class OsmoDocPdfConfig +{ + /// Required path to wkhtmltopdf binary, e.g. "wkhtmltopdf" on Linux + public static string? WkhtmltopdfPath { get; set; } +} diff --git a/OsmoDoc/Pdf/PdfDocumentGenerator.cs b/OsmoDoc/Pdf/PdfDocumentGenerator.cs new file mode 100644 index 0000000..d1f5c71 --- /dev/null +++ b/OsmoDoc/Pdf/PdfDocumentGenerator.cs @@ -0,0 +1,311 @@ +using OsmoDoc.Pdf.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace OsmoDoc.Pdf; + +public static class PdfDocumentGenerator +{ + private static ILogger _logger = NullLogger.Instance; + + /// + /// Configures logging for the WordDocumentGenerator + /// + /// Logger instance to use + public static void ConfigureLogging(ILogger logger) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Generates a PDF document from an HTML or EJS template. + /// + /// The path to the HTML or EJS template file. + /// A list of content metadata to replace placeholders in the template. + /// The desired output path for the generated PDF file. + /// A boolean indicating whether the template is an EJS file. + /// JSON string containing data for EJS template rendering. Required if isEjsTemplate is true. + public async static Task GeneratePdf(string templatePath, List metaDataList, string outputFilePath, bool isEjsTemplate, string? serializedEjsDataJson) + { + if (metaDataList is null) + { + throw new ArgumentNullException(nameof(metaDataList)); + } + + if (string.IsNullOrWhiteSpace(templatePath)) + { + throw new ArgumentNullException(nameof(templatePath)); + } + + if (string.IsNullOrWhiteSpace(outputFilePath)) + { + throw new ArgumentNullException(nameof(outputFilePath)); + } + + if (string.IsNullOrWhiteSpace(OsmoDocPdfConfig.WkhtmltopdfPath) && !RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + throw new Exception("WkhtmltopdfPath is not set in OsmoDocPdfConfig."); + } + + if (!File.Exists(templatePath)) + { + throw new Exception("The file path you provided is not valid."); + } + + string? ejsConvertedHtmlPath = null; + string? tempEjsDirectory = null; + string? tempModifiedHtmlDirectory = null; + + try + { + if (isEjsTemplate) + { + // Validate if template in file path is an ejs file + if (Path.GetExtension(templatePath).ToLower() != ".ejs") + { + throw new Exception("Input template should be a valid EJS file"); + } + + // Convert ejs file to an equivalent html + (ejsConvertedHtmlPath, tempEjsDirectory) = await ConvertEjsToHTML(templatePath, outputFilePath, serializedEjsDataJson); + templatePath = ejsConvertedHtmlPath; + } + + // Modify html template with content data and generate pdf + (string modifiedHtmlFilePath, string tempDirectory) = ReplaceFileElementsWithMetaData(templatePath, metaDataList, outputFilePath); + tempModifiedHtmlDirectory = tempDirectory; + + await ConvertHtmlToPdf(OsmoDocPdfConfig.WkhtmltopdfPath, modifiedHtmlFilePath, outputFilePath); + } + finally + { + // Cleanup temporary directories and files + CleanupTemporaryResources(ejsConvertedHtmlPath, tempEjsDirectory, tempModifiedHtmlDirectory); + } + } + + private static (string modifiedHtmlFilePath, string tempDirectory) ReplaceFileElementsWithMetaData(string templatePath, List metaDataList, string outputFilePath) + { + string htmlContent = File.ReadAllText(templatePath); + + foreach (ContentMetaData metaData in metaDataList) + { + htmlContent = htmlContent.Replace($"{{{{{metaData.Placeholder}}}}}", metaData.Content); + } + + string? directoryPath = Path.GetDirectoryName(outputFilePath); + if (directoryPath == null) + { + throw new Exception($"No directory found for the path: {outputFilePath}"); + } + string uniqueId = Guid.NewGuid().ToString("N"); + string tempHtmlFilePath = Path.Combine(directoryPath, $"Modified_{uniqueId}"); + string tempHtmlFile = Path.Combine(tempHtmlFilePath, "modifiedHtml.html"); + + if (!Directory.Exists(tempHtmlFilePath)) + { + Directory.CreateDirectory(tempHtmlFilePath); + } + + File.WriteAllText(tempHtmlFile, htmlContent); + return (tempHtmlFile, tempHtmlFilePath); + } + + private async static Task ConvertHtmlToPdf(string? wkhtmltopdfPath, string modifiedHtmlFilePath, string outputFilePath) + { + string fileName; + string arguments; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + fileName = "wkhtmltopdf"; + arguments = $"\"{modifiedHtmlFilePath}\" \"{outputFilePath}\""; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (string.IsNullOrWhiteSpace(wkhtmltopdfPath)) + { + throw new Exception("wkhtmltopdf path must be explicitly set on Windows."); + } + + string fullPath = Path.GetFullPath(wkhtmltopdfPath); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"wkhtmltopdf binary not found at: {fullPath}"); + } + + fileName = fullPath; + arguments = $"\"{modifiedHtmlFilePath}\" \"{outputFilePath}\""; + } + else + { + throw new PlatformNotSupportedException("Unsupported operating system."); + } + + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (Process process = new Process()) + { + process.StartInfo = psi; + process.Start(); + await process.WaitForExitAsync(); + string output = await process.StandardOutput.ReadToEndAsync(); + string errors = await process.StandardError.ReadToEndAsync(); + + if (process.ExitCode != 0) + { + throw new Exception($"Error during PDF generation: {errors} (Exit Code: {process.ExitCode})"); + } + } + } + + private async static Task<(string htmlPath, string tempDirectory)> ConvertEjsToHTML(string ejsFilePath, string outputFilePath, string? ejsDataJson) + { + // Generate directory + string? directoryPath = Path.GetDirectoryName(outputFilePath); + if (directoryPath == null) + { + throw new Exception($"No directory found for the path: {outputFilePath}"); + } + string uniqueId = Guid.NewGuid().ToString("N"); + string tempDirectoryFilePath = Path.Combine(directoryPath, $"Temp_{uniqueId}"); + + if (!Directory.Exists(tempDirectoryFilePath)) + { + Directory.CreateDirectory(tempDirectoryFilePath); + } + + // Generate file path to converted html template + string tempHtmlFilePath = Path.Combine(tempDirectoryFilePath, "htmlTemplate.html"); + + // If the ejs data json is invalid then throw exception + if (!string.IsNullOrWhiteSpace(ejsDataJson) && !IsValidJSON(ejsDataJson)) + { + throw new Exception("Received invalid JSON data for EJS template"); + } + + // Write json data string to json file + string ejsDataJsonFilePath = Path.Combine(tempDirectoryFilePath, "ejsData.json"); + string contentToWrite = ejsDataJson ?? "{}"; + File.WriteAllText(ejsDataJsonFilePath, contentToWrite); + + + // string commandLine = "cmd.exe"; + string commandLine = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "npx"; + string arguments = EjsToHtmlArgumentsBasedOnOS(ejsFilePath, ejsDataJsonFilePath, tempHtmlFilePath); + + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = commandLine, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (Process process = new Process()) + { + process.StartInfo = psi; + process.Start(); + await process.WaitForExitAsync(); + string output = await process.StandardOutput.ReadToEndAsync(); + string errors = await process.StandardError.ReadToEndAsync(); + + if (process.ExitCode != 0) + { + throw new Exception($"Error during EJS to HTML conversion: {errors} (Exit Code: {process.ExitCode})"); + } + } + + return (tempHtmlFilePath, tempDirectoryFilePath); + } + + private static bool IsValidJSON(string json) + { + try + { + JToken.Parse(json); + return true; + } + catch (Exception) + { + return false; + } + } + + private static string EjsToHtmlArgumentsBasedOnOS(string ejsFilePath, string ejsDataJsonFilePath, string tempHtmlFilePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return $"/C npx ejs \"{ejsFilePath}\" -f \"{ejsDataJsonFilePath}\" -o \"{tempHtmlFilePath}\""; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return $"ejs \"{ejsFilePath}\" -f \"{ejsDataJsonFilePath}\" -o \"{tempHtmlFilePath}\""; + } + else + { + throw new Exception("Unknown operating system"); + } + } + + private static void CleanupTemporaryResources(string? ejsConvertedHtmlPath, string? tempEjsDirectory, string? tempModifiedHtmlDirectory) + { + // Clean up EJS converted HTML file + if (!string.IsNullOrEmpty(ejsConvertedHtmlPath) && File.Exists(ejsConvertedHtmlPath)) + { + try + { + File.Delete(ejsConvertedHtmlPath); + } + catch (Exception ex) + { + // Log the exception but don't throw to avoid masking original exceptions + _logger.LogWarning(ex, $"Failed to delete EJS converted HTML file {ejsConvertedHtmlPath}"); + } + } + + // Clean up temp EJS directory + if (!string.IsNullOrEmpty(tempEjsDirectory) && Directory.Exists(tempEjsDirectory)) + { + try + { + Directory.Delete(tempEjsDirectory, recursive: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to delete temporary EJS directory {tempEjsDirectory}"); + } + } + + // Clean up temporary modified HTML directory and its contents + if (!string.IsNullOrEmpty(tempModifiedHtmlDirectory) && Directory.Exists(tempModifiedHtmlDirectory)) + { + try + { + Directory.Delete(tempModifiedHtmlDirectory, recursive: true); + } + catch (Exception ex) + { + // Log the exception but don't throw to avoid masking original exceptions + _logger.LogWarning(ex, $"Failed to delete temporary directory {tempModifiedHtmlDirectory}"); + } + } + } +} diff --git a/OsmoDoc/Services/Interfaces/IRedisTokenStoreService.cs b/OsmoDoc/Services/Interfaces/IRedisTokenStoreService.cs new file mode 100644 index 0000000..28eb8f0 --- /dev/null +++ b/OsmoDoc/Services/Interfaces/IRedisTokenStoreService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OsmoDoc.Services; + +public interface IRedisTokenStoreService +{ + Task StoreTokenAsync(string token, string email, CancellationToken cancellationToken = default); + Task IsTokenValidAsync(string token, CancellationToken cancellationToken = default); + Task RevokeTokenAsync(string token, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/OsmoDoc/Services/RedisTokenStoreService.cs b/OsmoDoc/Services/RedisTokenStoreService.cs new file mode 100644 index 0000000..3c19ad5 --- /dev/null +++ b/OsmoDoc/Services/RedisTokenStoreService.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using StackExchange.Redis; + +namespace OsmoDoc.Services; + +public class RedisTokenStoreService : IRedisTokenStoreService +{ + private readonly IDatabase _db; + private const string KeyPrefix = "valid_token:"; + + public RedisTokenStoreService(IConnectionMultiplexer redis) + { + this._db = redis.GetDatabase(); + } + + public Task StoreTokenAsync(string token, string email, CancellationToken cancellationToken = default) + { + // Check if operation was cancelled before starting + cancellationToken.ThrowIfCancellationRequested(); + + return this._db.StringSetAsync($"{KeyPrefix}{token}", JsonConvert.SerializeObject(new + { + issuedTo = email, + issuedAt = DateTime.UtcNow + })); + } + + public Task IsTokenValidAsync(string token, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return this._db.KeyExistsAsync($"{KeyPrefix}{token}"); + } + + public Task RevokeTokenAsync(string token, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return this._db.KeyDeleteAsync($"{KeyPrefix}{token}"); + } +} \ No newline at end of file diff --git a/OsmoDoc/Word/Models/ContentData.cs b/OsmoDoc/Word/Models/ContentData.cs new file mode 100644 index 0000000..dc27d31 --- /dev/null +++ b/OsmoDoc/Word/Models/ContentData.cs @@ -0,0 +1,29 @@ +namespace OsmoDoc.Word.Models; + + +/// +/// Represents the data for a content placeholder in a Word document. +/// +public class ContentData +{ + /// + /// Gets or sets the placeholder name. + /// + public string Placeholder { get; set; } = string.Empty; + + /// + /// Gets or sets the content to replace the placeholder with. + /// + public string Content { get; set; } = string.Empty; + + /// + /// Gets or sets the content type of the placeholder (text or image). + /// + public ContentType ContentType { get; set; } + + /// + /// Gets or sets the parent body of the placeholder (none or table). + /// + + public ParentBody ParentBody { get; set; } +} diff --git a/OsmoDoc/Word/Models/DocumentData.cs b/OsmoDoc/Word/Models/DocumentData.cs new file mode 100644 index 0000000..943d7d2 --- /dev/null +++ b/OsmoDoc/Word/Models/DocumentData.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace OsmoDoc.Word.Models; + + +/// +/// Represents the data for a Word document, including content placeholders and table data. +/// +public class DocumentData +{ + /// + /// Gets or sets the list of content placeholders in the document. + /// + public List Placeholders { get; set; } = new(); + + /// + /// Gets or sets the list of table data in the document. + /// + + public List TablesData { get; set; } = new(); + + /// + /// Gets or sets the list of images in the document. + /// + public List Images { get; set; } = new(); +} diff --git a/OsmoDoc/Word/Models/Enums.cs b/OsmoDoc/Word/Models/Enums.cs new file mode 100644 index 0000000..47d7ada --- /dev/null +++ b/OsmoDoc/Word/Models/Enums.cs @@ -0,0 +1,30 @@ +namespace OsmoDoc.Word.Models; + + +/// +/// Represents the content type of a placeholder in a Word document. +/// +public enum ContentType +{ + /// + /// The placeholder represents text content. + /// + Text = 0 +} + +/// +/// Represents the parent body of a placeholder in a Word document. +/// +public enum ParentBody +{ + /// + /// The placeholder does not have a parent body. + /// + None = 0, + + /// + /// The placeholder belongs to a table. + /// + + Table = 1 +} diff --git a/OsmoDoc/Word/Models/ImageData.cs b/OsmoDoc/Word/Models/ImageData.cs new file mode 100644 index 0000000..636a8e4 --- /dev/null +++ b/OsmoDoc/Word/Models/ImageData.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace OsmoDoc.Word.Models; + +public enum ImageSourceType +{ + Base64 = 0, + LocalFile = 1, + Url = 2 +} + +public class ImageData : IValidatableObject +{ + [Required(ErrorMessage = "Placeholder name is required")] + public string PlaceholderName { get; set; } = string.Empty; + + [Required(ErrorMessage = "Image source type is required")] + public ImageSourceType SourceType { get; set; } + + [Required(ErrorMessage = "Image data is required")] + public string Data { get; set; } = string.Empty; // Can be base64, file path, or URL + + public string? ImageExtension { get; set; } // Required for Base64 + + public IEnumerable Validate(ValidationContext validationContext) + { + if (this.SourceType == ImageSourceType.Base64) + { + if (string.IsNullOrWhiteSpace(this.ImageExtension)) + { + yield return new ValidationResult( + "Image extension is required for Base64 source type", + new[] { nameof(this.ImageExtension) } + ); + } + } + } +} \ No newline at end of file diff --git a/OsmoDoc/Word/Models/TableData.cs b/OsmoDoc/Word/Models/TableData.cs new file mode 100644 index 0000000..924c190 --- /dev/null +++ b/OsmoDoc/Word/Models/TableData.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace OsmoDoc.Word.Models; + + +/// +/// Represents the data for a table in a Word document. +/// +public class TableData +{ + /// + /// Gets or sets the position of the table in the document. + /// + public int TablePos { get; set; } + + /// + /// Gets or sets the list of dictionaries representing the data for each row in the table. + /// Each dictionary contains column header-value pairs. + /// + + public List> Data { get; set; } = new(); +} diff --git a/OsmoDoc/Word/WordDocumentGenerator.cs b/OsmoDoc/Word/WordDocumentGenerator.cs new file mode 100644 index 0000000..4b418c2 --- /dev/null +++ b/OsmoDoc/Word/WordDocumentGenerator.cs @@ -0,0 +1,480 @@ +using DocumentFormat.OpenXml.Drawing; +using DocumentFormat.OpenXml.Drawing.Wordprocessing; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OsmoDoc.Word.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using IOPath = System.IO.Path; +using Paragraph = DocumentFormat.OpenXml.Wordprocessing.Paragraph; +using Run = DocumentFormat.OpenXml.Wordprocessing.Run; +using Table = DocumentFormat.OpenXml.Wordprocessing.Table; +using TableCell = DocumentFormat.OpenXml.Wordprocessing.TableCell; +using TableCellProperties = DocumentFormat.OpenXml.Wordprocessing.TableCellProperties; +using TableRow = DocumentFormat.OpenXml.Wordprocessing.TableRow; +using Text = DocumentFormat.OpenXml.Wordprocessing.Text; + +namespace OsmoDoc.Word; + +/// +/// Provides functionality to generate Word documents based on templates and data. +/// +public static class WordDocumentGenerator +{ + private const string PlaceholderPattern = @"{[a-zA-Z][a-zA-Z0-9_-]*}"; + private static ILogger _logger = NullLogger.Instance; + + /// + /// Configures logging for the WordDocumentGenerator + /// + /// Logger instance to use + public static void ConfigureLogging(ILogger logger) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Generates a Word document based on a template, replaces placeholders with data, and saves it to the specified output file path. + /// + /// The file path of the template document. + /// The data to replace the placeholders in the template. + /// The file path to save the generated document. + public async static Task GenerateDocumentByTemplate(string templateFilePath, DocumentData documentData, string outputFilePath) + { + if (string.IsNullOrWhiteSpace(templateFilePath)) + { + throw new ArgumentNullException(nameof(templateFilePath)); + } + + if (documentData == null) + { + throw new ArgumentNullException(nameof(documentData)); + } + + if (string.IsNullOrWhiteSpace(outputFilePath)) + { + throw new ArgumentNullException(nameof(outputFilePath)); + } + + try + { + // Copy template to output location + File.Copy(templateFilePath, outputFilePath, true); + + using (WordprocessingDocument document = WordprocessingDocument.Open(outputFilePath, true)) + { + if (document.MainDocumentPart == null) + { + throw new InvalidOperationException("Document does not contain a main document part."); + } + + // Create dictionaries for each type of placeholders + Dictionary textPlaceholders = documentData.Placeholders + .Where(content => content.ParentBody == ParentBody.None && content.ContentType == ContentType.Text) + .ToDictionary(content => "{" + content.Placeholder + "}", content => content.Content); + + Dictionary tableContentPlaceholders = documentData.Placeholders + .Where(content => content.ParentBody == ParentBody.Table && content.ContentType == ContentType.Text) + .ToDictionary(content => "{" + content.Placeholder + "}", content => content.Content); + + // Replace text placeholders in main document + ReplaceTextPlaceholders(document.MainDocumentPart.Document, textPlaceholders); + + // Replace table placeholders and populate tables + ProcessTables(document.MainDocumentPart.Document, tableContentPlaceholders, documentData.TablesData); + + // Process images + await ProcessImagePlaceholders(document, documentData.Images); + + // Save the document + document.Save(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to generate document from template: {templateFilePath}"); + throw; // Re-throw for consumers to handle + } + } + + /// + /// Replaces text placeholders in the document. + /// + /// The document to process. + /// Dictionary of placeholders and their replacement values. + private static void ReplaceTextPlaceholders(Document document, Dictionary textPlaceholders) + { + if (textPlaceholders.Count == 0) + { + return; + } + + // Process all paragraphs in the document + List paragraphs = document.Descendants().ToList(); + + foreach (Paragraph paragraph in paragraphs) + { + // Get paragraph text to check for placeholders + string paragraphText = GetParagraphText(paragraph); + + if (string.IsNullOrEmpty(paragraphText) || !Regex.IsMatch(paragraphText, PlaceholderPattern)) + { + continue; + } + + // Replace placeholders in this paragraph + ReplacePlaceholdersInParagraph(paragraph, textPlaceholders); + } + } + + /// + /// Gets the text content of a paragraph. + /// + /// The paragraph to get text from. + /// The text content of the paragraph. + private static string GetParagraphText(Paragraph paragraph) + { + return string.Join("", paragraph.Descendants().Select(t => t.Text)); + } + + /// + /// Replaces placeholders in a specific paragraph. + /// + /// The paragraph to process. + /// Dictionary of placeholders and their replacement values. + private static void ReplacePlaceholdersInParagraph(Paragraph paragraph, Dictionary placeholders) + { + if (placeholders.Count == 0) + { + return; + } + + // Get all text elements from the paragraph + List textElements = paragraph.Descendants().ToList(); + if (textElements.Count == 0) + { + return; + } + + // Concatenate all text to get the full paragraph content + string fullText = string.Join("", textElements.Select(t => t.Text)); + + // Check if any placeholders exist in the full text + bool hasPlaceholders = placeholders.Keys.Any(placeholder => fullText.Contains(placeholder)); + if (!hasPlaceholders) + { + return; + } + + // Replace placeholders in the full text + string replacedText = fullText; + foreach (KeyValuePair placeholder in placeholders) + { + replacedText = replacedText.Replace(placeholder.Key, placeholder.Value); + } + + // If no changes were made, return + if (replacedText == fullText) + { + return; + } + + // Clear existing text elements and create a single new one + // This preserves the paragraph structure while ensuring text continuity + foreach (Text textElement in textElements) + { + textElement.Text = ""; + } + + // Use the first text element to hold all the replaced content + if (textElements.Count > 0) + { + textElements[0].Text = replacedText; + } + } + + /// + /// Processes tables for placeholder replacement and data population. + /// + /// The document containing tables. + /// Dictionary of table placeholders and their replacement values. + /// List of table data to populate. + private static void ProcessTables(Document document, Dictionary tableContentPlaceholders, List tablesData) + { + List tables = document.Descendants
().ToList(); + + foreach (Table table in tables) + { + // Replace placeholders in table cells + ReplaceTablePlaceholders(table, tableContentPlaceholders); + + // Populate table with data if applicable + int tableIndex = tables.IndexOf(table); + TableData? tableData = tablesData.FirstOrDefault(td => td.TablePos == tableIndex + 1); + + if (tableData != null) + { + PopulateTable(table, tableData); + } + } + } + + /// + /// Replaces placeholders in table cells. + /// + /// The table to process. + /// Dictionary of placeholders and their replacement values. + private static void ReplaceTablePlaceholders(Table table, Dictionary tableContentPlaceholders) + { + if (tableContentPlaceholders.Count == 0) + { + return; + } + + List tableRows = table.Elements().ToList(); + + foreach (TableRow row in tableRows) + { + List cells = row.Elements().ToList(); + + foreach (TableCell cell in cells) + { + List paragraphs = cell.Elements().ToList(); + + foreach (Paragraph paragraph in paragraphs) + { + string paragraphText = GetParagraphText(paragraph); + + if (string.IsNullOrEmpty(paragraphText) || !Regex.IsMatch(paragraphText, PlaceholderPattern)) + { + continue; + } + + ReplacePlaceholdersInParagraph(paragraph, tableContentPlaceholders); + } + } + } + } + + /// + /// Populates a table with data rows. + /// + /// The table to populate. + /// The data to populate the table with. + private static void PopulateTable(Table table, TableData tableData) + { + TableRow? headerRow = table.Elements().FirstOrDefault(); + if (headerRow == null) + { + return; + } + + List headerCells = headerRow.Elements().ToList(); + if (headerCells.Count == 0) + { + return; + } + + // Get column headers + List columnHeaders = headerCells.Select(cell => + { + Paragraph? firstParagraph = cell.Elements().FirstOrDefault(); + if (firstParagraph != null) + { + return string.Join("", firstParagraph.Descendants().Select(t => t.Text)); + } + return ""; + }).ToList(); + + // Add data rows + foreach (Dictionary rowData in tableData.Data) + { + TableRow newRow = new TableRow(); + + for (int i = 0; i < columnHeaders.Count; i++) + { + string cellValue = rowData.ContainsKey(columnHeaders[i]) ? rowData[columnHeaders[i]] : ""; + + TableCell cell = new TableCell( + new Paragraph( + new Run( + new Text(cellValue)))); + + // Copy formatting from header cell if available + if (i < headerCells.Count) + { + TableCellProperties? headerProps = headerCells[i].TableCellProperties; + if (headerProps != null) + { + cell.TableCellProperties = (TableCellProperties)headerProps.CloneNode(true); + } + } + + newRow.Append(cell); + } + + table.Append(newRow); + } + } + + /// + /// Processes image placeholders in the document. + /// + /// The Word document. + /// List of image data to process. + private static async Task ProcessImagePlaceholders(WordprocessingDocument document, List images) + { + if (images == null || !images.Any()) + { + return; + } + + List tempFiles = new List(); + + try + { + MainDocumentPart? mainPart = document.MainDocumentPart; + if (mainPart == null) + { + return; + } + + List drawings = mainPart.Document.Descendants().ToList(); + + foreach (ImageData img in images) + { + try + { + string tempFilePath = await PrepareImageFile(img); + tempFiles.Add(tempFilePath); + + Drawing? drawing = drawings.FirstOrDefault(d => + d.Descendants() + .Any(dp => dp.Description == img.PlaceholderName)); + + if (drawing == null) + { + continue; + } + + foreach (Blip blip in drawing.Descendants()) + { + if (blip.Embed?.Value == null) + { + continue; + } + + OpenXmlPart imagePart = mainPart.GetPartById(blip.Embed!); + using (Stream partStream = imagePart.GetStream(FileMode.Create)) + using (FileStream fileStream = File.OpenRead(tempFilePath)) + { + await fileStream.CopyToAsync(partStream); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to process image placeholder: {img.PlaceholderName}"); + } + } + } + finally + { + // Clean up temp files + foreach (string file in tempFiles) + { + try + { + File.Delete(file); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to delete temporary file: {file}."); + } + } + } + } + + /// + /// Prepares an image file from various sources (Base64, local file, URL). + /// + /// The image data containing source information. + /// Path to the prepared temporary image file. + private static async Task PrepareImageFile(ImageData imageData) + { + string tempFilePath = IOPath.GetTempFileName(); + + // Determine image extension + string extension = ".jpg"; // Default fallback + + if (!string.IsNullOrEmpty(imageData.ImageExtension)) + { + extension = imageData.ImageExtension.StartsWith(".") + ? imageData.ImageExtension + : "." + imageData.ImageExtension; + } + else if (imageData.SourceType == ImageSourceType.LocalFile || imageData.SourceType == ImageSourceType.Url) + { + try + { + extension = System.IO.Path.GetExtension(imageData.Data); + if (string.IsNullOrEmpty(extension)) + { + extension = ".jpg"; // fallback if no extension in path/URL + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to determine image extension from path: {imageData.Data}"); + extension = ".jpg"; // safe fallback on exception + } + } + + // Define allowed image extensions + string[] allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".svg" }; + if (!allowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Invalid image extension: {extension}"); + } + + tempFilePath = IOPath.ChangeExtension(tempFilePath, extension); + + switch (imageData.SourceType) + { + case ImageSourceType.Base64: + await File.WriteAllBytesAsync( + tempFilePath, + Convert.FromBase64String(imageData.Data)); + break; + + case ImageSourceType.LocalFile: + string fullPath = IOPath.GetFullPath(imageData.Data); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException("Image file not found", fullPath); + } + File.Copy(fullPath, tempFilePath, true); + break; + + case ImageSourceType.Url: + using (HttpClient httpClient = new HttpClient()) + { + byte[] bytes = await httpClient.GetByteArrayAsync(imageData.Data); + await File.WriteAllBytesAsync(tempFilePath, bytes); + } + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + return tempFilePath; + } +} \ No newline at end of file diff --git a/README.md b/README.md index fcbf352..38ef022 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# DocumentService -DocumentService is a library with the following functions +# OsmoDoc +OsmoDoc is a library with the following functions 1. **Generate Word documents** - Read Word document files as a template and replace the placeholder with actual data. 2. **Generate PDF documents** - Read an HTML file as a template and replace placeholders with actual data. Convert the HTML file to PDF @@ -24,32 +24,32 @@ Setting up the app in a Docker-based environment enables developers of non-Windo 1. [Install Docker](https://docs.docker.com/engine/install/) on your machine. Choose to follow the instructions based on your device OS. 2. [Install Docker Compose](https://docs.docker.com/compose/install/). A separate installation is required for Linux-based OS. If you are using Windows or macOS, installing the Docker Desktop app includes Docker Compose. -3. Clone the project `document-service`. +3. Clone the project `osmodoc`. 4. (Optional) [Install Docker Extension for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker). 5. In the root directory of the project, create a new file `.env`. 6. Copy data from [example template](.env.example) into `.env`. Then set suitable JWT key. 7. Set `environment` variables `ASPNETCORE_ENVIRONMENT` and `BUILD_CONFIGURATION` as per requirement in [docker-compose.yaml](./docker-compose.yaml). Ensure correct formatting: -#### Development (Default Configuration) +#### Development ```yaml - - ASPNETCORE_ENVIRONMENT=Development - BUILD_CONFIGURATION=Debug + - ASPNETCORE_ENVIRONMENT=Development ``` #### Testing/Staging ```yaml - - ASPNETCORE_ENVIRONMENT=Development - BUILD_CONFIGURATION=Release + - ASPNETCORE_ENVIRONMENT=Development ``` #### Production ```yaml - - ASPNETCORE_ENVIRONMENT=Production - BUILD_CONFIGURATION=Release + - ASPNETCORE_ENVIRONMENT=Production ``` 8. Ensure Docker is running. -9. Execute the following commands to dockerize `document-service` using `docker-compose.yaml`: +9. Execute the following commands to dockerize `osmodoc` using `docker-compose.yaml`: ```shell # build the container @@ -100,7 +100,7 @@ docker compose up - Finish Installation. ## Including wkhtmltopdf executable file to build package -- Go to the location to the bin files of your project where the DocumentService DLL is located. +- Go to the location to the bin files of your project where the OsmoDoc DLL is located. - Create a folder called Tools and place the wkhtmltopdf.exe file there. wkhtmltopdf.exe can be found in the Program Files in C directory after it is installed. Note: We use a Temp folder to temporarily hold the modified HTML file before converting it to a PDF file. After the conversion is done, the temporary file is removed. The code is already provided with the location of the temp file, so no modification is required in the code, and the temp folder will be used automatically. @@ -108,7 +108,12 @@ Note: We use a Temp folder to temporarily hold the modified HTML file before con # Basic usage ## PDF generation + +#### HTML TO PDF ```csharp +string htmlTemplateFilePath = @"C:\Path\To\Template.html"; +string outputFilePath = @"C:\Path\To\GeneratedOutput.pdf"; + List contentList = new List { new ContentMetaData { Placeholder = "Incident UID", Content = "I-20230822-001" }, @@ -116,70 +121,143 @@ List contentList = new List new ContentMetaData { Placeholder = "Site", Content = "Headquarters" } }; -// Tools\\index.html - The path of the html template file in which changes are to be made. -// Tools\\OutputFile.pdf - The path of the final pdf output file. -PdfDocumentGenerator.GeneratePdfByTemplate("Tools\\index.html", contentList, "Tools\\OutputFile.pdf"); +await PdfDocumentGenerator.GeneratePdf(htmlTemplateFilePath, contentList, outputFilePath, isEjsTemplate: false, serializedEjsDataJson: null); +``` + +#### EJS TO PDF +```csharp +string htmlTemplateFilePath = @"C:\Path\To\Template.ejs"; +string outputFilePath = @"C:\Path\To\GeneratedOutput.pdf"; +string serializedEjsDataJson = "{\"title\": \"EJS Test\", \"user\": {\"name\": \"Jane\"}}" + +List contentList = new List{}; + +await PdfDocumentGenerator.GeneratePdf(htmlTemplateFilePath, contentList, outputFilePath, isEjsTemplate: true, serializedEjsDataJson: serializedEjsDataJson); ``` ## Word document generation ```csharp -string templateFilePath = @"C:\Users\Admin\Desktop\Osmosys\Work\Projects\Document Service Component\Testing\Document.docx"; -string outputFilePath = @"C:\Users\Admin\Desktop\Osmosys\Work\Projects\Document Service Component\Testing\Test_Output.docx"; +string templateFilePath = @"C:\Path\To\Template.docx"; +string outputFilePath = @"C:\Path\To\GeneratedOutput.docx"; + +// Text placeholders (optional) +List placeholders = new List() +{ + new ContentData + { + Placeholder = "InvoiceNo", + Content = "INV-20250618", + ContentType = ContentType.Text, + ParentBody = ParentBody.None + }, + new ContentData + { + Placeholder = "InvoiceDate", + Content = "18 June 2025", + ContentType = ContentType.Text, + ParentBody = ParentBody.None + }, + new ContentData + { + Placeholder = "TableCellNote", + Content = "Thanks for using OsmoDoc", + ContentType = ContentType.Text, + ParentBody = ParentBody.Table + }, + new ContentData + { + Placeholder = "CustomerName", + Content = "John Doe", + ContentType = ContentType.Text, + ParentBody = ParentBody.None + } +}; +// Table data example List tablesData = new List() +{ + new TableData() { - new TableData() + TablePos = 1, + Data = new List>() + { + new Dictionary() + { + { "Item", "Laptop" }, + { "Oty", "2" }, + { "Price", "60000" } + }, + new Dictionary() { - TablePos = 5, - Data = new List>() - { - new Dictionary() - { - { "Item Name", "1st med" }, - { "Dosage", "1" }, - { "Quantity", "1" }, - { "Precautions", "Take care" } - }, - new Dictionary() - { - { "Item Name", "2nd med" }, - { "Dosage", "1" }, - { "Quantity", "1" }, - } - } + { "Item", "Mouse" }, + { "Oty", "5" }, + { "Price", "500" } } - }; - - List contents = new List() + } + }, + new TableData() { - new ContentData + TablePos = 2, + Data = new List>() { - Placeholder = "Picture 1", - Content = @"../testImage1.jpg", - ContentType = ContentType.Image, - ParentBody = ParentBody.None - }, - new ContentData - { - Placeholder = "Picture 2", - Content = @"../testImage2.jpg", - ContentType = ContentType.Image, - ParentBody = ParentBody.None - }, - }; - - DocumentData documentData = new DocumentData() + new Dictionary() + { + { "TaxType", "CGST" }, + { "Amount", "900" } + }, + new Dictionary() + { + { "TaxType", "SGST" }, + { "Amount", "600" } + } + } + } +}; + + +// Image data for different source types +List images = new List() +{ + // Local file + new ImageData + { + PlaceholderName = "Picture 1", // Alt text of image placeholder in Word + SourceType = ImageSourceType.LocalFile, + Data = @"C:\Images\logo.png" + }, + + // URL + new ImageData + { + PlaceholderName = "Picture 2", + SourceType = ImageSourceType.Url, + Data = "https://example.com/image.jpg" + }, + + // Base64 (ImageExtension is required when SourceType is Base64) + new ImageData { - Placeholders = contents, - TablesData = tablesData - }; + PlaceholderName = "Picture 3", + SourceType = ImageSourceType.Base64, + Data = "", + ImageExtension = ".jpg" + } +}; - WordDocumentGenerator.GenerateDocumentByTemplate(templateFilePath, documentData, outputFilePath); +// Combine all document parts +DocumentData documentData = new DocumentData +{ + Placeholders = placeholders, + TablesData = tablesData, + Images = images +}; + +// Generate final Word document +await WordDocumentGenerator.GenerateDocumentByTemplate(templateFilePath, documentData, outputFilePath); ``` # Targeted frameworks -1. .NET Framework 4.5.2 -2. .NET Standard 2.0 - Can be installed as a dependency in applications running on .NET Framework 4.8 and modern .NET (Core, v5, v6 and later). +1. .NET Framework 8.0 # Citations - [NPOI](https://github.com/nissl-lab/npoi) @@ -187,11 +265,12 @@ List tablesData = new List() - [wkhtmltopdf](https://wkhtmltopdf.org/) # License -The DocumentService is licensed under the [MIT](https://github.com/OsmosysSoftware/document-service/blob/main/LICENSE) license. +The OsmoDoc is licensed under the [MIT](https://github.com/OsmosysSoftware/osmodoc/blob/main/LICENSE) license. + ## 👏 Big Thanks to Our Contributors - - Contributors + + Contributors We appreciate the time and effort put in by all contributors to make this project better! \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 8df18d6..d83f6fa 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,15 +1,32 @@ services: - document-service: + redis: + image: redis:7 + container_name: ${COMPOSE_PROJECT_NAME}-redis + env_file: + - .env + ports: + - ${REDIS_PORT}:6379 + networks: + - osmodoc-net + + osmodoc-api: build: context: . dockerfile: Dockerfile - image: document-service-docker - container_name: document-service-api + container_name: ${COMPOSE_PROJECT_NAME}-api env_file: - .env ports: - - 5000:5000 + - ${SERVER_PORT}:5000 environment: - - ASPNETCORE_URLS=http://+:5000 - - ASPNETCORE_ENVIRONMENT=Development - - BUILD_CONFIGURATION=Release + - REDIS_URL=${REDIS_HOST}:${REDIS_PORT} + depends_on: + - redis + volumes: + - ./Temp:/app/wwwroot/Temp + networks: + - osmodoc-net + +networks: + osmodoc-net: + driver: bridge \ No newline at end of file diff --git a/docs/site/10.0.2/api/DocumentService.Word.Models.ContentData.html b/docs/site/10.0.2/api/OsmoDoc.Word.Models.ContentData.html similarity index 81% rename from docs/site/10.0.2/api/DocumentService.Word.Models.ContentData.html rename to docs/site/10.0.2/api/OsmoDoc.Word.Models.ContentData.html index 240969e..c7f4db0 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.Models.ContentData.html +++ b/docs/site/10.0.2/api/OsmoDoc.Word.Models.ContentData.html @@ -67,11 +67,11 @@
-
+
-

Class ContentData +

Class ContentData

Represents the data for a content placeholder in a Word document.
@@ -104,16 +104,16 @@
Inherited Members
object.MemberwiseClone()
-
Namespace: DocumentService.Word.Models
-
Assembly: DocumentService.dll
-
Syntax
+
Namespace: OsmoDoc.Word.Models
+
Assembly: OsmoDoc.dll
+
Syntax
public class ContentData

Properties

- -

Content

+ +

Content

Gets or sets the content to replace the placeholder with.
Declaration
@@ -135,8 +135,8 @@
Property Value
- -

ContentType

+ +

ContentType

Gets or sets the content type of the placeholder (text or image).
Declaration
@@ -153,13 +153,13 @@
Property Value
- ContentType + ContentType - -

ParentBody

+ +

ParentBody

Gets or sets the parent body of the placeholder (none or table).
Declaration
@@ -176,13 +176,13 @@
Property Value
- ParentBody + ParentBody - -

Placeholder

+ +

Placeholder

Gets or sets the placeholder name.
Declaration
diff --git a/docs/site/10.0.2/api/DocumentService.Word.Models.ContentType.html b/docs/site/10.0.2/api/OsmoDoc.Word.Models.ContentType.html similarity index 86% rename from docs/site/10.0.2/api/DocumentService.Word.Models.ContentType.html rename to docs/site/10.0.2/api/OsmoDoc.Word.Models.ContentType.html index 976b450..53b1147 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.Models.ContentType.html +++ b/docs/site/10.0.2/api/OsmoDoc.Word.Models.ContentType.html @@ -67,18 +67,18 @@
-
+
-

Enum ContentType +

Enum ContentType

Represents the content type of a placeholder in a Word document.
-
Namespace: DocumentService.Word.Models
-
Assembly: DocumentService.dll
-
Syntax
+
Namespace: OsmoDoc.Word.Models
+
Assembly: OsmoDoc.dll
+
Syntax
public enum ContentType
@@ -93,11 +93,11 @@

Fields - Image + Image The placeholder represents an image. - Text + Text The placeholder represents text content. diff --git a/docs/site/10.0.2/api/DocumentService.Word.Models.DocumentData.html b/docs/site/10.0.2/api/OsmoDoc.Word.Models.DocumentData.html similarity index 84% rename from docs/site/10.0.2/api/DocumentService.Word.Models.DocumentData.html rename to docs/site/10.0.2/api/OsmoDoc.Word.Models.DocumentData.html index 22bee61..e7bde9c 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.Models.DocumentData.html +++ b/docs/site/10.0.2/api/OsmoDoc.Word.Models.DocumentData.html @@ -67,11 +67,11 @@

-
+
-

Class DocumentData +

Class DocumentData

Represents the data for a Word document, including content placeholders and table data.
@@ -104,16 +104,16 @@
Inherited Members
object.MemberwiseClone()
-
Namespace: DocumentService.Word.Models
-
Assembly: DocumentService.dll
-
Syntax
+
Namespace: OsmoDoc.Word.Models
+
Assembly: OsmoDoc.dll
+
Syntax
public class DocumentData

Properties

- -

Placeholders

+ +

Placeholders

Gets or sets the list of content placeholders in the document.
Declaration
@@ -130,13 +130,13 @@
Property Value
- List<ContentData> + List<ContentData> - -

TablesData

+ +

TablesData

Gets or sets the list of table data in the document.
Declaration
@@ -153,7 +153,7 @@
Property Value
- List<TableData> + List<TableData> diff --git a/docs/site/10.0.2/api/DocumentService.Word.Models.ParentBody.html b/docs/site/10.0.2/api/OsmoDoc.Word.Models.ParentBody.html similarity index 86% rename from docs/site/10.0.2/api/DocumentService.Word.Models.ParentBody.html rename to docs/site/10.0.2/api/OsmoDoc.Word.Models.ParentBody.html index 30027df..89ec062 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.Models.ParentBody.html +++ b/docs/site/10.0.2/api/OsmoDoc.Word.Models.ParentBody.html @@ -67,18 +67,18 @@
-
+
-

Enum ParentBody +

Enum ParentBody

Represents the parent body of a placeholder in a Word document.
-
Namespace: DocumentService.Word.Models
-
Assembly: DocumentService.dll
-
Syntax
+
Namespace: OsmoDoc.Word.Models
+
Assembly: OsmoDoc.dll
+
Syntax
public enum ParentBody
@@ -93,11 +93,11 @@

Fields - None + None The placeholder does not have a parent body. - Table + Table The placeholder belongs to a table. diff --git a/docs/site/10.0.2/api/DocumentService.Word.Models.TableData.html b/docs/site/10.0.2/api/OsmoDoc.Word.Models.TableData.html similarity index 87% rename from docs/site/10.0.2/api/DocumentService.Word.Models.TableData.html rename to docs/site/10.0.2/api/OsmoDoc.Word.Models.TableData.html index 2f6f1bf..8a48600 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.Models.TableData.html +++ b/docs/site/10.0.2/api/OsmoDoc.Word.Models.TableData.html @@ -67,11 +67,11 @@

-
+
-

Class TableData +

Class TableData

Represents the data for a table in a Word document.
@@ -104,16 +104,16 @@
Inherited Members
object.MemberwiseClone()
-
Namespace: DocumentService.Word.Models
-
Assembly: DocumentService.dll
-
Syntax
+
Namespace: OsmoDoc.Word.Models
+
Assembly: OsmoDoc.dll
+
Syntax
public class TableData

Properties

- -

Data

+ +

Data

Gets or sets the list of dictionaries representing the data for each row in the table. Each dictionary contains column header-value pairs.
@@ -136,8 +136,8 @@
Property Value
- -

TablePos

+ +

TablePos

Gets or sets the position of the table in the document.
Declaration
diff --git a/docs/site/10.0.2/api/DocumentService.Word.Models.html b/docs/site/10.0.2/api/OsmoDoc.Word.Models.html similarity index 84% rename from docs/site/10.0.2/api/DocumentService.Word.Models.html rename to docs/site/10.0.2/api/OsmoDoc.Word.Models.html index bdb06e8..7d574b8 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.Models.html +++ b/docs/site/10.0.2/api/OsmoDoc.Word.Models.html @@ -5,10 +5,10 @@ - Namespace DocumentService.Word.Models + <title>Namespace OsmoDoc.Word.Models | Some Documentation - @@ -67,9 +67,9 @@
-
+
-

Namespace DocumentService.Word.Models +

Namespace OsmoDoc.Word.Models

@@ -77,18 +77,18 @@

Classes

-

ContentData

+

ContentData

Represents the data for a content placeholder in a Word document.
-

DocumentData

+

DocumentData

Represents the data for a Word document, including content placeholders and table data.
-

TableData

+

TableData

Represents the data for a table in a Word document.

Enums

-

ContentType

+

ContentType

Represents the content type of a placeholder in a Word document.
-

ParentBody

+

ParentBody

Represents the parent body of a placeholder in a Word document.
diff --git a/docs/site/10.0.2/api/DocumentService.Word.WordDocumentGenerator.html b/docs/site/10.0.2/api/OsmoDoc.Word.WordDocumentGenerator.html similarity index 85% rename from docs/site/10.0.2/api/DocumentService.Word.WordDocumentGenerator.html rename to docs/site/10.0.2/api/OsmoDoc.Word.WordDocumentGenerator.html index 68e403b..3bf7445 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.WordDocumentGenerator.html +++ b/docs/site/10.0.2/api/OsmoDoc.Word.WordDocumentGenerator.html @@ -67,11 +67,11 @@
-
+
-

Class WordDocumentGenerator +

Class WordDocumentGenerator

Provides functionality to generate Word documents based on templates and data.
@@ -104,16 +104,16 @@
Inherited Members
object.MemberwiseClone()
-
Namespace: DocumentService.Word
-
Assembly: DocumentService.dll
-
Syntax
+
Namespace: OsmoDoc.Word
+
Assembly: OsmoDoc.dll
+
Syntax
public static class WordDocumentGenerator

Methods

- -

GenerateDocumentByTemplate(string, DocumentData, string)

+ +

GenerateDocumentByTemplate(string, DocumentData, string)

Generates a Word document based on a template, replaces placeholders with data, and saves it to the specified output file path.
Declaration
@@ -136,7 +136,7 @@
Parameters
The file path of the template document. - DocumentData + DocumentData documentData The data to replace the placeholders in the template. diff --git a/docs/site/10.0.2/api/DocumentService.Word.html b/docs/site/10.0.2/api/OsmoDocWord.html similarity index 91% rename from docs/site/10.0.2/api/DocumentService.Word.html rename to docs/site/10.0.2/api/OsmoDocWord.html index b4b7697..2e1c7cb 100644 --- a/docs/site/10.0.2/api/DocumentService.Word.html +++ b/docs/site/10.0.2/api/OsmoDocWord.html @@ -5,10 +5,10 @@ - Namespace DocumentService.Word + <title>Namespace OsmoDoc.Word | Some Documentation - @@ -67,9 +67,9 @@
-
+
-

Namespace DocumentService.Word +

Namespace OsmoDoc.Word

@@ -77,7 +77,7 @@

Classes

-

WordDocumentGenerator

+

WordDocumentGenerator

Provides functionality to generate Word documents based on templates and data.
diff --git a/docs/site/10.0.2/api/toc.html b/docs/site/10.0.2/api/toc.html index 005aae4..74aad52 100644 --- a/docs/site/10.0.2/api/toc.html +++ b/docs/site/10.0.2/api/toc.html @@ -14,33 +14,33 @@