Epub Font Manager (#4037)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-09-21 11:35:28 -05:00 committed by GitHub
parent ba01a38d20
commit d04b8a09a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
124 changed files with 5329 additions and 136 deletions

1
.gitignore vendored
View File

@ -508,6 +508,7 @@ UI/Web/dist/
/API/config/logs/
/API/config/backups/
/API/config/cache/
/API/config/fonts/
/API/config/temp/
/API/config/themes/
/API/config/stats/

View File

@ -190,6 +190,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="config\fonts\" />
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Font;
using API.Entities.Enums.Font;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using MimeTypes;
namespace API.Controllers;
[Authorize]
public class FontController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService;
private readonly IFontService _fontService;
private readonly IMapper _mapper;
private readonly ILocalizationService _localizationService;
private readonly Regex _fontFileExtensionRegex = new(Parser.FontFileExtensions, RegexOptions.IgnoreCase, Parser.RegexTimeout);
public FontController(IUnitOfWork unitOfWork, IDirectoryService directoryService,
IFontService fontService, IMapper mapper, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_directoryService = directoryService;
_fontService = fontService;
_mapper = mapper;
_localizationService = localizationService;
}
/// <summary>
/// List out the fonts
/// </summary>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)]
[HttpGet("all")]
public async Task<ActionResult<IEnumerable<EpubFontDto>>> GetFonts()
{
return Ok(await _unitOfWork.EpubFontRepository.GetFontDtosAsync());
}
/// <summary>
/// Returns a font file
/// </summary>
/// <param name="fontId"></param>
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetFont(int fontId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null) return NotFound();
if (font.Provider == FontProvider.System) return BadRequest("System provided fonts are not loaded by API");
var contentType = MimeTypeMap.GetMimeType(Path.GetExtension(font.FileName));
var path = Path.Join(_directoryService.EpubFontDirectory, font.FileName);
return PhysicalFile(path, contentType, true);
}
/// <summary>
/// Removes a font from the system
/// </summary>
/// <param name="fontId"></param>
/// <param name="force">If the font is in use by other users and an admin wants it deleted, they must confirm to force delete it. This is prompted in the UI.</param>
/// <returns></returns>
[HttpDelete]
public async Task<IActionResult> DeleteFont(int fontId, bool force = false)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied"));
var forceDelete = User.IsInRole(PolicyConstants.AdminRole) && force;
var fontInUse = await _fontService.IsFontInUse(fontId);
if (!fontInUse || forceDelete)
{
await _fontService.Delete(fontId);
}
return Ok();
}
/// <summary>
/// Returns if the given font is in use by any other user. System provided fonts will always return true.
/// </summary>
/// <param name="fontId"></param>
/// <returns></returns>
[HttpGet("in-use")]
public async Task<ActionResult<bool>> IsFontInUse(int fontId)
{
return Ok(await _fontService.IsFontInUse(fontId));
}
/// <summary>
/// Manual upload
/// </summary>
/// <param name="formFile"></param>
/// <returns></returns>
[HttpPost("upload")]
public async Task<ActionResult<EpubFontDto>> UploadFont(IFormFile formFile)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied"));
if (!_fontFileExtensionRegex.IsMatch(Path.GetExtension(formFile.FileName))) return BadRequest("Invalid file");
if (formFile.FileName.Contains("..")) return BadRequest("Invalid file");
var tempFile = await UploadToTemp(formFile);
var font = await _fontService.CreateFontFromFileAsync(tempFile);
return Ok(_mapper.Map<EpubFontDto>(font));
}
[HttpPost("upload-by-url")]
public async Task<ActionResult> UploadFontByUrl([FromQuery]string url)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied"));
// Validate url
try
{
var font = await _fontService.CreateFontFromUrl(url);
return Ok(_mapper.Map<EpubFontDto>(font));
}
catch (KavitaException ex)
{
return BadRequest(_localizationService.Translate(User.GetUserId(), ex.Message));
}
}
private async Task<string> UploadToTemp(IFormFile file)
{
var outputFile = Path.Join(_directoryService.TempDirectory, file.FileName);
await using var stream = System.IO.File.Create(outputFile);
await file.CopyToAsync(stream);
stream.Close();
return outputFile;
}
}

View File

@ -40,7 +40,7 @@ public class ThemeController : BaseApiController
_mapper = mapper;
}
[ResponseCache(CacheProfileName = "10Minute")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)]
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult<IEnumerable<SiteThemeDto>>> GetThemes()

View File

@ -0,0 +1,13 @@
using System;
using API.Entities.Enums.Font;
namespace API.DTOs.Font;
public sealed record EpubFontDto
{
public int Id { get; set; }
public string Name { get; set; }
public FontProvider Provider { get; set; }
public string FileName { get; set; }
}

View File

@ -81,6 +81,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
public DbSet<AppUserAnnotation> AppUserAnnotation { get; set; } = null!;
public DbSet<EpubFont> EpubFont { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class CustomEpubFonts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EpubFont",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", nullable: true),
FileName = table.Column<string>(type: "TEXT", nullable: true),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EpubFont", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EpubFont");
}
}
}

View File

@ -1321,6 +1321,41 @@ namespace API.Data.Migrations
b.ToTable("EmailHistory");
});
modelBuilder.Entity("API.Entities.EpubFont", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("EpubFont");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")

View File

@ -0,0 +1,102 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Font;
using API.Entities;
using API.Extensions;
using API.Services.Tasks;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IEpubFontRepository
{
void Add(EpubFont font);
void Remove(EpubFont font);
void Update(EpubFont font);
Task<IEnumerable<EpubFontDto>> GetFontDtosAsync();
Task<EpubFontDto?> GetFontDtoAsync(int fontId);
Task<EpubFontDto?> GetFontDtoByNameAsync(string name);
Task<IEnumerable<EpubFont>> GetFontsAsync();
Task<EpubFont?> GetFontAsync(int fontId);
Task<bool> IsFontInUseAsync(int fontId);
}
public class EpubFontRepository: IEpubFontRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public EpubFontRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Add(EpubFont font)
{
_context.Add(font);
}
public void Remove(EpubFont font)
{
_context.Remove(font);
}
public void Update(EpubFont font)
{
_context.Entry(font).State = EntityState.Modified;
}
public async Task<IEnumerable<EpubFontDto>> GetFontDtosAsync()
{
return await _context.EpubFont
.OrderBy(s => s.Name == FontService.DefaultFont ? -1 : 0)
.ThenBy(s => s)
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<EpubFontDto?> GetFontDtoAsync(int fontId)
{
return await _context.EpubFont
.Where(f => f.Id == fontId)
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<EpubFontDto?> GetFontDtoByNameAsync(string name)
{
return await _context.EpubFont
.Where(f => f.NormalizedName.Equals(name.ToNormalized()))
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<EpubFont>> GetFontsAsync()
{
return await _context.EpubFont
.ToListAsync();
}
public async Task<EpubFont?> GetFontAsync(int fontId)
{
return await _context.EpubFont
.Where(f => f.Id == fontId)
.FirstOrDefaultAsync();
}
public async Task<bool> IsFontInUseAsync(int fontId)
{
return await _context.AppUserReadingProfiles
.Join(_context.EpubFont,
preference => preference.BookReaderFontFamily,
font => font.Name,
(preference, font) => new { preference, font })
.AnyAsync(joined => joined.font.Id == fontId);
}
}

View File

@ -84,6 +84,7 @@ public interface IUserRepository
Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds);
Task<AppUser?> GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None);
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByFontAsync(string fontName);
Task<bool> HasAccessToLibrary(int libraryId, int userId);
Task<bool> HasAccessToSeries(int userId, int seriesId);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true);
@ -293,6 +294,14 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByFontAsync(string fontName)
{
return await _context.AppUserPreferences
.Where(p => p.BookReaderFontFamily == fontName)
.AsSplitQuery()
.ToListAsync();
}
public async Task<bool> HasAccessToLibrary(int libraryId, int userId)
{
return await _context.Library

View File

@ -12,10 +12,13 @@ using API.Data.Repositories;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Font;
using API.Entities.Enums.Theme;
using API.Entities.MetadataMatching;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Identity;
@ -69,6 +72,87 @@ public static class Seed
}
];
public static readonly ImmutableArray<EpubFont> DefaultFonts =
[
new ()
{
Name = FontService.DefaultFont,
NormalizedName = Parser.Normalize(FontService.DefaultFont),
Provider = FontProvider.System,
FileName = string.Empty,
},
new ()
{
Name = "Merriweather",
NormalizedName = Parser.Normalize("Merriweather"),
Provider = FontProvider.System,
FileName = "Merriweather-Regular.woff2",
},
new ()
{
Name = "EB Garamond",
NormalizedName = Parser.Normalize("EB Garamond"),
Provider = FontProvider.System,
FileName = "EBGaramond-VariableFont_wght.woff2",
},
new ()
{
Name = "Fira Sans",
NormalizedName = Parser.Normalize("Fira Sans"),
Provider = FontProvider.System,
FileName = "FiraSans-Regular.woff2",
},
new ()
{
Name = "Lato",
NormalizedName = Parser.Normalize("Lato"),
Provider = FontProvider.System,
FileName = "Lato-Regular.woff2",
},
new ()
{
Name = "Libre Baskerville",
NormalizedName = Parser.Normalize("Libre Baskerville"),
Provider = FontProvider.System,
FileName = "LibreBaskerville-Regular.woff2",
},
new ()
{
Name = "Nanum Gothic",
NormalizedName = Parser.Normalize("Nanum Gothic"),
Provider = FontProvider.System,
FileName = "NanumGothic-Regular.woff2",
},
new ()
{
Name = "Open Dyslexic",
NormalizedName = Parser.Normalize("Open Dyslexic"),
Provider = FontProvider.System,
FileName = "OpenDyslexic-Regular.woff2",
},
new ()
{
Name = "RocknRoll One",
NormalizedName = Parser.Normalize("RocknRoll One"),
Provider = FontProvider.System,
FileName = "RocknRollOne-Regular.woff2",
},
new ()
{
Name = "Fast Font Serif",
NormalizedName = Parser.Normalize("Fast Font Serif"),
Provider = FontProvider.System,
FileName = "Fast_Serif.woff2",
},
new ()
{
Name = "Fast Font Sans",
NormalizedName = Parser.Normalize("Fast Font Sans"),
Provider = FontProvider.System,
FileName = "Fast_Sans.woff2",
}
];
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
..new List<SiteTheme>
{
@ -197,7 +281,7 @@ public static class Seed
foreach (var theme in DefaultThemes)
{
var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name));
var existing = await context.SiteTheme.FirstOrDefaultAsync(s => s.Name.Equals(theme.Name));
if (existing == null)
{
await context.SiteTheme.AddAsync(theme);
@ -207,6 +291,22 @@ public static class Seed
await context.SaveChangesAsync();
}
public static async Task SeedFonts(DataContext context)
{
await context.Database.EnsureCreatedAsync();
foreach (var font in DefaultFonts)
{
var existing = await context.EpubFont.FirstOrDefaultAsync(f => f.Name.Equals(font.Name));
if (existing == null)
{
await context.EpubFont.AddAsync(font);
}
}
await context.SaveChangesAsync();
}
public static async Task SeedDefaultStreams(IUnitOfWork unitOfWork)
{
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.DashboardStreams);

View File

@ -35,6 +35,7 @@ public interface IUnitOfWork
IEmailHistoryRepository EmailHistoryRepository { get; }
IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
IAnnotationRepository AnnotationRepository { get; }
IEpubFontRepository EpubFontRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -78,6 +79,7 @@ public class UnitOfWork : IUnitOfWork
EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper);
AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper);
AnnotationRepository = new AnnotationRepository(_context, _mapper);
EpubFontRepository = new EpubFontRepository(_context, _mapper);
}
/// <summary>
@ -109,6 +111,7 @@ public class UnitOfWork : IUnitOfWork
public IEmailHistoryRepository EmailHistoryRepository { get; }
public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
public IAnnotationRepository AnnotationRepository { get; }
public IEpubFontRepository EpubFontRepository { get; }
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View File

@ -2,6 +2,7 @@
using API.Data;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Services.Tasks;
namespace API.Entities;
@ -79,7 +80,7 @@ public class AppUserPreferences
/// <summary>
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
/// </summary>
public string BookReaderFontFamily { get; set; } = "default";
public string BookReaderFontFamily { get; set; } = FontService.DefaultFont;
/// <summary>
/// Book Reader Option: Allows tapping on side of screens to paginate
/// </summary>

View File

@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Services.Tasks;
namespace API.Entities;
@ -109,7 +110,7 @@ public class AppUserReadingProfile
/// <summary>
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
/// </summary>
public string BookReaderFontFamily { get; set; } = "default";
public string BookReaderFontFamily { get; set; } = FontService.DefaultFont;
/// <summary>
/// Book Reader Option: Allows tapping on side of screens to paginate
/// </summary>

View File

@ -0,0 +1,13 @@
namespace API.Entities.Enums.Font;
public enum FontProvider
{
/// <summary>
/// Font is provider by System, always avaible
/// </summary>
System = 1,
/// <summary>
/// Font provider by the User
/// </summary>
User = 2,
}

37
API/Entities/EpubFont.cs Normal file
View File

@ -0,0 +1,37 @@
using System;
using API.Entities.Enums.Font;
using API.Entities.Interfaces;
using API.Services;
namespace API.Entities;
/// <summary>
/// Represents a user provider font to be used in the epub reader
/// </summary>
public class EpubFont: IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Name of the font
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Normalized name for lookups
/// </summary>
public required string NormalizedName { get; set; }
/// <summary>
/// Filename of the font, stored under <see cref="DirectoryService.EpubFontDirectory"/>
/// </summary>
/// <remarks>System provided fonts use an alternative location as they are packaged with the app</remarks>
public required string FileName { get; set; }
/// <summary>
/// Where the font came from
/// </summary>
public FontProvider Provider { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View File

@ -59,6 +59,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IPersonService, PersonService>();
services.AddScoped<IReadingProfileService, ReadingProfileService>();
services.AddScoped<IKoreaderService, KoreaderService>();
services.AddScoped<IFontService, FontService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IProcessSeries, ProcessSeries>();

View File

@ -11,6 +11,7 @@ using API.DTOs.Device;
using API.DTOs.Email;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Font;
using API.DTOs.KavitaPlus.Manage;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.MediaErrors;
@ -282,6 +283,8 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(src => src.BookThemeName));
CreateMap<EpubFont, EpubFontDto>();
CreateMap<AppUserBookmark, BookmarkDto>();

View File

@ -241,6 +241,6 @@
"update-yearly-stats": "Update Yearly Stats",
"generated-reading-profile-name": "Generated from {0}",
"genre-doesnt-exist": "Genre doesn't exist"
"genre-doesnt-exist": "Genre doesn't exist",
"font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts"
}

View File

@ -124,6 +124,7 @@ public class Program
await Seed.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
await Seed.SeedSettings(context, directoryService);
await Seed.SeedThemes(context);
await Seed.SeedFonts(context);
await Seed.SeedDefaultStreams(unitOfWork);
await Seed.SeedDefaultSideNavStreams(unitOfWork);
await Seed.SeedUserApiKeys(context);

View File

@ -313,11 +313,11 @@ public partial class BookService : IBookService
}
/// <summary>
/// For each bookmark on this page, inject a specialized icon
/// For each ptoc (text) bookmark on this page, inject a specialized icon
/// </summary>
/// <param name="doc"></param>
/// <param name="ptocBookmarks"></param>
private void InjectPTOCBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
private void InjectTextBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
{
if (ptocBookmarks.Count == 0) return;
@ -1257,7 +1257,7 @@ public partial class BookService : IBookService
InjectImages(doc, book, apiBase);
// Inject PTOC Bookmark Icons
InjectPTOCBookmarks(doc, ptocBookmarks);
InjectTextBookmarks(doc, ptocBookmarks);
// Inject Annotations
InjectAnnotations(doc, annotations);

View File

@ -42,6 +42,7 @@ public interface IDirectoryService
/// Used for random files needed, like images to check against, list of countries, etc
/// </summary>
string AssetsDirectory { get; }
string EpubFontDirectory { get; }
/// <summary>
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
/// </summary>
@ -100,6 +101,8 @@ public class DirectoryService : IDirectoryService
public string TemplateDirectory { get; }
public string PublisherDirectory { get; }
public string LongTermCacheDirectory { get; }
public string EpubFontDirectory { get; }
private readonly ILogger<DirectoryService> _logger;
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
@ -141,6 +144,8 @@ public class DirectoryService : IDirectoryService
ExistOrCreate(PublisherDirectory);
LongTermCacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache-long");
ExistOrCreate(LongTermCacheDirectory);
EpubFontDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "fonts");
ExistOrCreate(EpubFontDirectory);
}
/// <summary>

264
API/Services/FontService.cs Normal file
View File

@ -0,0 +1,264 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums.Font;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Flurl.Http;
using Kavita.Common;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks;
// Although we don't use all the fields, just including them all for completeness
internal class GoogleFontsMetadata
{
/// <summary>
/// Name of the zip file container all fonts
/// </summary>
public required string zipName { get; init; }
/// <summary>
/// Manifest, information about the content of the zip
/// </summary>
public required GoogleFontsManifest manifest { get; init; }
/// <summary>
/// Tries to find the variable font in the manifest
/// </summary>
/// <returns>GoogleFontsFileRef</returns>
public GoogleFontsFileRef? VariableFont()
{
foreach (var fileRef in manifest.fileRefs)
{
// Filename prefixed with static means it's a Bold/Italic/... font
if (!fileRef.filename.StartsWith("static/"))
{
return fileRef;
}
}
return null;
}
}
internal class GoogleFontsManifest
{
/// <summary>
/// Files included in the zip
/// <example>README.txt</example>
/// </summary>
public required GoogleFontsFile[] files { get; init; }
/// <summary>
/// References to the actual fonts
/// </summary>
public required GoogleFontsFileRef[] fileRefs { get; init; }
}
internal class GoogleFontsFile
{
public required string filename { get; init; }
public required string contents { get; init; }
}
internal class GoogleFontsFileRef
{
public required string filename { get; init; }
public required string url { get; init; }
public required GoogleFontsData date { get; init; }
}
internal class GoogleFontsData
{
public required int seconds { get; init; }
public required int nanos { get; init; }
}
public interface IFontService
{
Task<EpubFont> CreateFontFromFileAsync(string path);
Task Delete(int fontId);
Task<EpubFont> CreateFontFromUrl(string url);
Task<bool> IsFontInUse(int fontId);
}
public class FontService: IFontService
{
public static readonly string DefaultFont = "Default";
private readonly IDirectoryService _directoryService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<FontService> _logger;
private readonly IEventHub _eventHub;
private const string SupportedFontUrlPrefix = "https://fonts.google.com/";
private const string DownloadFontUrlPrefix = "https://fonts.google.com/download/list?family=";
private const string GoogleFontsInvalidJsonPrefix = ")]}'";
public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, ILogger<FontService> logger, IEventHub eventHub)
{
_directoryService = directoryService;
_unitOfWork = unitOfWork;
_logger = logger;
_eventHub = eventHub;
}
public async Task<EpubFont> CreateFontFromFileAsync(string path)
{
if (!_directoryService.FileSystem.File.Exists(path))
{
_logger.LogInformation("Unable to create font from manual upload as font not in temp");
throw new KavitaException("errors.font-manual-upload");
}
var fileName = _directoryService.FileSystem.FileInfo.New(path).Name;
var nakedFileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(fileName);
var fontName = Parser.PrettifyFileName(nakedFileName);
var normalizedName = Parser.Normalize(nakedFileName);
if (await _unitOfWork.EpubFontRepository.GetFontDtoByNameAsync(fontName) != null)
{
throw new KavitaException("errors.font-already-in-use");
}
_directoryService.CopyFileToDirectory(path, _directoryService.EpubFontDirectory);
var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, fileName);
var font = new EpubFont()
{
Name = fontName,
NormalizedName = normalizedName,
FileName = Path.GetFileName(finalLocation),
Provider = FontProvider.User
};
_unitOfWork.EpubFontRepository.Add(font);
await _unitOfWork.CommitAsync();
// TODO: Send update to UI
return font;
}
/// <summary>
/// This does not check if in use, use <see cref="IsFontInUse"/>
/// </summary>
/// <param name="fontId"></param>
public async Task Delete(int fontId)
{
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null) return;
await RemoveFont(font);
}
public async Task<EpubFont> CreateFontFromUrl(string url)
{
if (!url.StartsWith(SupportedFontUrlPrefix))
{
throw new KavitaException("font-url-not-allowed");
}
// Extract Font name from url
var fontFamily = url.Split(SupportedFontUrlPrefix)[1].Split("?")[0].Split("/").Last();
_logger.LogInformation("Preparing to download {FontName} font", fontFamily.Sanitize());
var metaData = await GetGoogleFontsMetadataAsync(fontFamily);
if (metaData == null)
{
_logger.LogError("Unable to find metadata for {FontName}", fontFamily.Sanitize());
throw new KavitaException("errors.font-not-found");
}
var googleFontRef = metaData.VariableFont();
if (googleFontRef == null)
{
_logger.LogError("Unable to find variable font for {FontName} with metadata {MetaData}", fontFamily.Sanitize(), metaData);
throw new KavitaException("errors.font-not-found");
}
var fontExt = Path.GetExtension(googleFontRef.filename);
var fileName = $"{fontFamily}{fontExt}";
_logger.LogDebug("Downloading font {FontFamily} to {FileName} from {Url}", fontFamily.Sanitize(), fileName, googleFontRef.url);
var path = await googleFontRef.url.DownloadFileAsync(_directoryService.TempDirectory, fileName);
return await CreateFontFromFileAsync(path);
}
/// <summary>
/// Returns if the given font is in use by any other user. System provided fonts will always return true.
/// </summary>
/// <param name="fontId"></param>
/// <returns></returns>
public async Task<bool> IsFontInUse(int fontId)
{
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null || font.Provider == FontProvider.System) return true;
return await _unitOfWork.EpubFontRepository.IsFontInUseAsync(fontId);
}
public async Task RemoveFont(EpubFont font)
{
if (font.Provider == FontProvider.System) return;
var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByFontAsync(font.Name);
foreach (var pref in prefs)
{
pref.BookReaderFontFamily = DefaultFont;
_unitOfWork.UserRepository.Update(pref);
}
try
{
// Copy the font file to temp for nightly removal (to give user time to reclaim if made a mistake)
var existingLocation =
_directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, font.FileName);
var newLocation =
_directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, font.FileName);
_directoryService.CopyFileToDirectory(existingLocation, newLocation);
_directoryService.DeleteFiles([existingLocation]);
}
catch (Exception) { /* Swallow */ }
_unitOfWork.EpubFontRepository.Remove(font);
await _unitOfWork.CommitAsync();
}
private async Task<GoogleFontsMetadata?> GetGoogleFontsMetadataAsync(string fontName)
{
var url = DownloadFontUrlPrefix + fontName;
string content;
// The request may fail if the users URL is invalid or the font doesn't exist
// The error this produces is ugly and not user-friendly, so we catch it here
try
{
content = await url
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.GetStringAsync();
} catch (Exception ex)
{
_logger.LogError(ex, "Unable to get metadata for {FontName} from {Url}", fontName.Sanitize(), url);
return null;
}
// The returned response isn't valid json and has this weird prefix, removing it here...
if (content.StartsWith(GoogleFontsInvalidJsonPrefix))
{
content = content[GoogleFontsInvalidJsonPrefix.Length..];
}
return JsonSerializer.Deserialize<GoogleFontsMetadata>(content);
}
}

View File

@ -124,6 +124,9 @@ public class BackupService : IBackupService
await SendProgress(0.5F, "Copying bookmarks");
await CopyBookmarksToBackupDirectory(tempDirectory);
await SendProgress(0.6F, "Copying Fonts");
CopyFontsToBackupDirectory(tempDirectory);
await SendProgress(0.75F, "Copying themes");
CopyThemesToBackupDirectory(tempDirectory);
@ -225,6 +228,26 @@ public class BackupService : IBackupService
}
}
private void CopyFontsToBackupDirectory(string tempDirectory)
{
var outputTempDir = Path.Join(tempDirectory, "fonts");
_directoryService.ExistOrCreate(outputTempDir);
try
{
_directoryService.CopyDirectoryToDirectory(_directoryService.EpubFontDirectory, outputTempDir);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Failed to copy fonts to backup directory '{OutputTempDir}'. Fonts will not be included in the backup.", outputTempDir);
}
if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any())
{
_directoryService.ClearAndDeleteDirectory(outputTempDir);
}
}
private void CopyThemesToBackupDirectory(string tempDirectory)
{
var outputTempDir = Path.Join(tempDirectory, "themes");

View File

@ -31,6 +31,7 @@ public static partial class Parser
private const string BookFileExtensions = EpubFileExtension + "|" + PdfFileExtension;
private const string XmlRegexExtensions = @"\.xml";
public const string MacOsMetadataFileStartsWith = @"._";
public const string FontFileExtensions = @"\.[woff2|ttf|otf|woff]";
public const string SupportedExtensions =
ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions;
@ -1257,6 +1258,14 @@ public static partial class Parser
return filename;
}
/**
* Replaced non-alphanumerical chars with a space
*/
public static string PrettifyFileName(string name)
{
return Regex.Replace(name, "[^a-zA-Z0-9]", " ");
}
/// <summary>
/// Removes duplicate chapter markers from filename, keeping only the first occurrence
/// </summary>

View File

@ -0,0 +1,17 @@
/**
* Where does the font come from
*/
export enum FontProvider {
System = 1,
User = 2,
}
/**
* Font used in the book reader
*/
export interface EpubFont {
id: number;
name: string;
provider: FontProvider;
fileName: string;
}

View File

@ -22,4 +22,5 @@ export enum WikiLink {
OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients',
Guides = 'https://wiki.kavitareader.com/guides',
ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/",
EpubFontManager = "https://wiki.kavitareader.com/guides/epub-fonts/",
}

View File

@ -6,7 +6,6 @@ import {WritingStyle} from '../_models/preferences/writing-style';
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
import {FormControl, FormGroup, NonNullableFormBuilder} from "@angular/forms";
import {ReadingProfile, ReadingProfileKind} from "../_models/preferences/reading-profiles";
import {BookService, FontFamily} from "../book-reader/_services/book.service";
import {ThemeService} from './theme.service';
import {ReadingProfileService} from "./reading-profile.service";
import {debounceTime, distinctUntilChanged, filter, tap} from "rxjs/operators";
@ -18,6 +17,8 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
import {LayoutMeasurementService} from "./layout-measurement.service";
import {environment} from "../../environments/environment";
import {EpubFont} from "../_models/preferences/epub-font";
import {FontService} from "./font.service";
export interface ReaderSettingUpdate {
setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme';
@ -43,7 +44,7 @@ const COLUMN_GAP = 20; //px gap between columns
@Injectable()
export class EpubReaderSettingsService {
private readonly destroyRef = inject(DestroyRef);
private readonly bookService = inject(BookService);
private readonly fontService = inject(FontService);
private readonly themeService = inject(ThemeService);
private readonly readingProfileService = inject(ReadingProfileService);
private readonly utilityService = inject(UtilityService);
@ -57,6 +58,7 @@ export class EpubReaderSettingsService {
private readonly _parentReadingProfile = signal<ReadingProfile | null>(null);
private readonly _currentSeriesId = signal<number | null>(null);
private readonly _isInitialized = signal<boolean>(false);
private readonly _epubFonts = signal<EpubFont[]>([]);
// Settings signals
private readonly _pageStyles = signal<PageStyle>(this.getDefaultPageStyles()); // Internal property used to capture all the different css properties to render on all elements
@ -70,7 +72,6 @@ export class EpubReaderSettingsService {
// Form will be managed separately but updated from signals
private settingsForm!: BookReadingProfileFormGroup;
private fontFamilies: FontFamily[] = this.bookService.getFontFamilies();
private isUpdatingFromForm = false; // Flag to prevent infinite loops
private isInitialized = this._isInitialized(); // Non-signal, updates in effect
@ -89,6 +90,7 @@ export class EpubReaderSettingsService {
public readonly clickToPaginate = this._clickToPaginate.asReadonly();
public readonly immersiveMode = this._immersiveMode.asReadonly();
public readonly isFullscreen = this._isFullscreen.asReadonly();
public readonly epubFonts = this._epubFonts.asReadonly();
// Computed signals for derived state
public readonly layoutMode = computed(() => {
@ -215,6 +217,9 @@ export class EpubReaderSettingsService {
* Initialize the service with a reading profile and series ID
*/
async initialize(seriesId: number, readingProfile: ReadingProfile): Promise<void> {
const fonts = await firstValueFrom(this.fontService.getFonts());
this._epubFonts.set(fonts);
this._currentSeriesId.set(seriesId);
this._currentReadingProfile.set(readingProfile);
@ -248,7 +253,7 @@ export class EpubReaderSettingsService {
private setupDefaultsFromProfile(profile: ReadingProfile): void {
// Set defaults if undefined
if (profile.bookReaderFontFamily === undefined) {
profile.bookReaderFontFamily = 'default';
profile.bookReaderFontFamily = FontService.DefaultEpubFont;
}
if (profile.bookReaderFontSize === undefined || profile.bookReaderFontSize < 50) {
profile.bookReaderFontSize = 100;
@ -299,13 +304,6 @@ export class EpubReaderSettingsService {
return this._currentReadingProfile();
}
/**
* Get font families for UI
*/
getFontFamilies(): FontFamily[] {
return this.fontFamilies;
}
/**
* Get available themes
*/
@ -490,11 +488,11 @@ export class EpubReaderSettingsService {
).subscribe(fontName => {
this.isUpdatingFromForm = true;
const familyName = this.fontFamilies.find(f => f.title === fontName)?.family || 'default';
const familyName = this._epubFonts().find(f => f.name === fontName)?.name || FontService.DefaultEpubFont;
const currentStyles = this._pageStyles();
const newStyles = { ...currentStyles };
if (familyName === 'default') {
if (familyName === FontService.DefaultEpubFont) {
newStyles['font-family'] = 'inherit';
} else {
newStyles['font-family'] = `'${familyName}'`;
@ -670,7 +668,7 @@ export class EpubReaderSettingsService {
const currentStyles = this._pageStyles();
const newStyles: PageStyle = {
'font-family': fontFamily || currentStyles['font-family'] || 'default',
'font-family': fontFamily || currentStyles['font-family'] || FontService.DefaultEpubFont,
'font-size': fontSize || currentStyles['font-size'] || '100%',
'margin-left': margin || currentStyles['margin-left'] || defaultMargin,
'margin-right': margin || currentStyles['margin-right'] || defaultMargin,
@ -682,7 +680,7 @@ export class EpubReaderSettingsService {
public getDefaultPageStyles(): PageStyle {
return {
'font-family': 'default',
'font-family': FontService.DefaultEpubFont,
'font-size': '100%',
'margin-left': '15vw',
'margin-right': '15vw',

View File

@ -0,0 +1,66 @@
import {effect, inject, Injectable} from "@angular/core";
import {EpubFont, FontProvider} from "../_models/preferences/epub-font";
import {environment} from 'src/environments/environment';
import {HttpClient} from "@angular/common/http";
import {NgxFileDropEntry} from "ngx-file-drop";
import {AccountService} from "./account.service";
import {TextResonse} from "../_types/text-response";
import {map} from "rxjs/operators";
@Injectable({
providedIn: 'root'
})
export class FontService {
public static readonly DefaultEpubFont = 'Default';
private readonly httpClient = inject(HttpClient);
private readonly accountService = inject(AccountService);
baseUrl = environment.apiUrl;
apiKey: string = '';
encodedKey: string = '';
constructor() {
effect(() => {
const user = this.accountService.currentUserSignal();
if (user) {
this.apiKey = user.apiKey;
this.encodedKey = encodeURIComponent(this.apiKey);
}
});
}
getFonts() {
return this.httpClient.get<Array<EpubFont>>(this.baseUrl + 'font/all');
}
getFontFace(font: EpubFont): FontFace {
// TODO: We need to refactor this so that we loadFonts with an array, fonts have an id to remove them, and we don't keep populating the document
if (font.provider === FontProvider.System) {
return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`);
}
return new FontFace(font.name, `url(${this.baseUrl}font?fontId=${font.id}&apiKey=${this.encodedKey})`);
}
uploadFont(fontFile: File, fileEntry: NgxFileDropEntry) {
const formData = new FormData();
formData.append('formFile', fontFile, fileEntry.relativePath);
return this.httpClient.post<EpubFont>(this.baseUrl + "font/upload", formData);
}
uploadFromUrl(url: string) {
return this.httpClient.post<EpubFont>(this.baseUrl + "font/upload-by-url?url=" + encodeURIComponent(url), {});
}
deleteFont(id: number, force: boolean = false) {
return this.httpClient.delete(this.baseUrl + `font?fontId=${id}&force=${force}`);
}
isFontInUse(id: number) {
return this.httpClient.get(this.baseUrl + `font/in-use?fontId=${id}`, TextResonse).pipe(map(res => res == 'true'));
}
}

View File

@ -1,59 +1,3 @@
@font-face {
font-family: "Fira_Sans";
src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url(../../../../assets/fonts/Lato/Lato-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Libre_Baskerville";
src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Merriweather";
src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Nanum_Gothic";
src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "RocknRoll_One";
src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic2";
src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "FastFontSerif";
src: url(../../../../assets/fonts/Fast_Font/Fast_Serif.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "FastFontSans";
src: url(../../../../assets/fonts/Fast_Font/Fast_Sans.woff2) format("woff2");
font-display: swap;
}
:root {
--br-actionbar-button-text-color: #6c757d;
--accordion-body-bg-color: black;

View File

@ -67,6 +67,7 @@ import {LayoutMeasurementService} from "../../../_services/layout-measurement.se
import {ColorscapeService} from "../../../_services/colorscape.service";
import {environment} from "../../../../environments/environment";
import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
import {FontService} from "../../../_services/font.service";
import afterFrame from "afterframe";
@ -156,6 +157,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly layoutService = inject(LayoutMeasurementService);
private readonly colorscapeService = inject(ColorscapeService);
private readonly fontService = inject(FontService);
protected readonly BookPageLayoutMode = BookPageLayoutMode;
protected readonly WritingStyle = WritingStyle;
@ -776,6 +778,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
async ngOnInit() {
this.fontService.getFonts().subscribe(fonts => {
fonts.filter(f => f.name !== FontService.DefaultEpubFont).forEach(font => {
this.fontService.getFontFace(font).load().then(loadedFace => {
(this.document as any).fonts.add(loadedFace);
});
});
});
const libraryId = this.route.snapshot.paramMap.get('libraryId');
const seriesId = this.route.snapshot.paramMap.get('seriesId');
const chapterId = this.route.snapshot.paramMap.get('chapterId');

View File

@ -16,8 +16,8 @@
<div class="mb-3">
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
@for(opt of fontOptions; track opt) {
<option [value]="opt">{{opt | titlecase}}</option>
@for(opt of epubFonts(); track opt) {
<option [value]="opt.name">{{opt.name | titlecase}}</option>
}
</select>
</div>

View File

@ -6,7 +6,6 @@ import {BookTheme} from 'src/app/_models/preferences/book-theme';
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
import {WritingStyle} from 'src/app/_models/preferences/writing-style';
import {ThemeProvider} from 'src/app/_models/preferences/site-theme';
import {FontFamily} from '../../_services/book.service';
import {BookBlackTheme} from '../../_models/book-black-theme';
import {BookDarkTheme} from '../../_models/book-dark-theme';
import {BookWhiteTheme} from '../../_models/book-white-theme';
@ -23,6 +22,9 @@ import {
import {TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
import {BookReadingProfileFormGroup, EpubReaderSettingsService} from "../../../_services/epub-reader-settings.service";
import {LayoutMode} from "../../../manga-reader/_models/layout-mode";
import {FontService} from "../../../_services/font.service";
import {EpubFont} from "../../../_models/preferences/epub-font";
/**
* Used for book reader. Do not use for other components
@ -95,11 +97,6 @@ export class ReaderSettingsComponent implements OnInit {
@Input({required:true}) readingProfile!: ReadingProfile;
@Input({required:true}) readerSettingsService!: EpubReaderSettingsService;
/**
* List of all font families user can select from
*/
fontOptions: Array<string> = [];
fontFamilies: Array<FontFamily> = [];
settingsForm!: BookReadingProfileFormGroup;
/**
* System provided themes
@ -118,6 +115,7 @@ export class ReaderSettingsComponent implements OnInit {
protected hasParentProfile!: Signal<boolean>;
protected parentReadingProfile!: Signal<ReadingProfile | null>;
protected currentReadingProfile!: Signal<ReadingProfile | null>;
protected epubFonts!: Signal<EpubFont[]>;
protected isVerticalLayout!: Signal<boolean>;
@ -136,18 +134,17 @@ export class ReaderSettingsComponent implements OnInit {
this.hasParentProfile = this.readerSettingsService.hasParentProfile;
this.parentReadingProfile = this.readerSettingsService.parentReadingProfile;
this.currentReadingProfile = this.readerSettingsService.currentReadingProfile;
this.epubFonts = this.readerSettingsService.epubFonts;
this.themes = this.readerSettingsService.getThemes();
// Initialize the service if not already done
if (!this.readerSettingsService.getCurrentReadingProfile()) {
await this.readerSettingsService.initialize(this.seriesId, this.readingProfile);
}
this.settingsForm = this.readerSettingsService.getSettingsForm();
this.fontFamilies = this.readerSettingsService.getFontFamilies();
this.fontOptions = this.fontFamilies.map(f => f.title);
this.cdRef.markForCheck();
}

View File

@ -5,17 +5,6 @@ import {environment} from 'src/environments/environment';
import {BookChapterItem} from '../_models/book-chapter-item';
import {BookInfo} from '../_models/book-info';
export interface FontFamily {
/**
* What the user should see
*/
title: string;
/**
* The actual font face
*/
family: string;
}
@Injectable({
providedIn: 'root'
})
@ -25,13 +14,6 @@ export class BookService {
private readonly baseUrl = environment.apiUrl;
getFontFamilies(): Array<FontFamily> {
return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'},
{title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'},
{title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'},
{title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}];
}
getBookChapters(chapterId: number) {
return this.http.get<Array<BookChapterItem>>(this.baseUrl + 'book/' + chapterId + '/chapters');
}

View File

@ -201,6 +201,14 @@
}
}
@defer (when fragment === SettingsTabId.Font; prefetch on idle) {
@if (fragment === SettingsTabId.Font) {
<div class="scale col-md-12">
<app-font-manager />
</div>
}
}
@defer (when fragment === SettingsTabId.Devices; prefetch on idle) {
@if (fragment === SettingsTabId.Devices) {
<div class="scale col-md-12">

View File

@ -60,6 +60,7 @@ import {
} from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component";
import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component";
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
import {FontManagerComponent} from "../../../user-settings/font-manager/font-manager/font-manager.component";
@Component({
selector: 'app-settings',
@ -99,7 +100,8 @@ import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect
ManageReadingProfilesComponent,
ManageOpenIDConnectComponent,
ManagePublicMetadataSettingsComponent,
ImportMappingsComponent
ImportMappingsComponent,
FontManagerComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',

View File

@ -53,6 +53,7 @@ export enum SettingsTabId {
Account = 'account',
Preferences = 'preferences',
ReadingProfiles = 'reading-profiles',
Font = 'font',
Clients = 'clients',
Theme = 'theme',
Devices = 'devices',
@ -224,6 +225,7 @@ export class PreferenceNavComponent implements AfterViewInit {
new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]),
new SideNavItem(SettingsTabId.Clients),
new SideNavItem(SettingsTabId.Theme),
new SideNavItem(SettingsTabId.Font),
new SideNavItem(SettingsTabId.Devices),
new SideNavItem(SettingsTabId.UserStats),
]

View File

@ -0,0 +1,154 @@
<ng-container *transloco="let t; prefix:'font-manager'">
<div class="container-fluid g-0">
<p [innerHTML]="t('description', {'wikiLink': WikiLink})"></p>
<form [formGroup]="form">
<div class="row g-0 theme-container">
<div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller">
<div class="pe-2">
<div class="d-flex justify-content-between mb-2 align-items-center {{!selectedFont() ? 'active' : ''}}">
<button class="btn btn-outline-primary btn-sm me-1" (click)="selectFont(undefined)" [disabled]="selectedFont() === undefined || isReadOnly()">
<i class="fa fa-plus me-1" aria-hidden="true"></i>{{t("add")}}
</button>
<div class="form-check form-switch">
<input type="checkbox" formControlName="filter" class="form-check-input" id="filter-out-system-fonts" (change)="hideSystemFonts.set(!hideSystemFonts())">
<label for="filter-out-system-fonts" class="form-check-label">{{t('filter-system-fonts-label')}}</label>
</div>
</div>
<ul style="height: 100%" class="list-group list-group-flush">
@for (font of visibleFonts(); track font.id) {
<ng-container [ngTemplateOutlet]="fontOption" [ngTemplateOutletContext]="{ $implicit: font}"></ng-container>
} @empty {
<app-loading [loading]="fonts().length === 0"></app-loading>
}
</ul>
</div>
</div>
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3">
<div class="card p-3">
@let currentFont = selectedFont();
@if (currentFont === undefined) {
<div class="row pb-4">
<div class="mx-auto">
<div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly">
{{t('preview-default')}}
</div>
</div>
</div>
</div>
@if (files && files.length > 0) {
<app-loading [loading]="isUploadingFont()"></app-loading>
} @else {
<ngx-file-drop (onFileDrop)="dropped($event)" [accept]="acceptableExtensions" [directory]="false"
dropZoneClassName="file-upload" contentClassName="file-upload-zone">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
@switch (uploadMode()) {
@case ('all') {
<div class="row g-0 mt-3 pb-3">
<div class="mx-auto">
<div class="row g-0 mb-3">
<i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i>
</div>
<div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly">
<a class="pe-0" href="javascript:void(0)" (click)="uploadMode.set('url')">
<span class="phone-hidden">{{t('enter-an-url-pre-title', {url: ''})}}</span>{{t('url')}}
</a>
<span class="ps-1 pe-1"></span>
<span class="pe-0" href="javascript:void(0)">{{t('drag-n-drop')}}</span>
<span class="ps-1 pe-1"></span>
<a class="pe-0" href="javascript:void(0)" (click)="openFileSelector()">{{t('upload')}}<span class="phone-hidden"> {{t('upload-continued')}}</span></a>
</div>
</div>
</div>
</div>
}
@case ('url') {
<div class="row g-0 mt-3 pb-3 ms-md-2 me-md-2">
<div class="input-group col-auto me-md-2" style="width: 83%">
<label class="input-group-text" for="load-url">{{t('url-label')}}</label>
<input type="text" autofocus autocomplete="off" class="form-control" formControlName="fontUrl"
placeholder="https://" id="load-url">
<button class="btn btn-outline-secondary" type="button" (click)="uploadFromUrl(); uploadMode.set('all');"
[disabled]="(form.get('fontUrl')?.value).length === 0">
{{t('load')}}
</button>
</div>
<button class="btn btn-secondary col-auto" href="javascript:void(0)" (click)="uploadMode.set('all')">
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>&nbsp;
<span class="phone-hidden">{{t('back')}}</span>
</button>
</div>
}
}
</ng-template>
</ngx-file-drop>
}
} @else {
<h4>
{{currentFont.name | sentenceCase}}
<div class="float-end">
@if (currentFont.provider !== FontProvider.System && currentFont.name !== FontService.DefaultEpubFont) {
<button class="btn btn-danger me-1" (click)="deleteFont(currentFont.id)" [disabled]="isReadOnly()">{{t('delete')}}</button>
}
</div>
</h4>
<div>
<ng-container [ngTemplateOutlet]="availableFont" [ngTemplateOutletContext]="{ $implicit: currentFont}"></ng-container>
</div>
}
</div>
</div>
</div>
</form>
</div>
<ng-template #fontOption let-item>
@if (item !== undefined) {
@let currentFont = selectedFont();
<li
[@loadNewFontAnimation]="animationState(item)"
class="list-group-item d-flex justify-content-between align-items-start clickable rounded mt-1"
[class.active]="currentFont && currentFont?.name === item.name"
(click)="selectFont(item)"
>
<div class="ms-2 me-auto">
<div class="fw-bold">{{item.name | sentenceCase}}</div>
</div>
<div>
<span class="pill p-1 mx-1 provider">{{item.provider | siteThemeProvider}}</span>
</div>
</li>
}
</ng-template>
<ng-template #availableFont let-item>
@if (item) {
<div class="d-flex justify-content-between w-100">
@if (item.name === FontService.DefaultEpubFont) {
<div class="ms-2 me-auto fs-6">
{{t('no-preview')}}
</div>
}
</div>
@if (item.name !== FontService.DefaultEpubFont) {
<div class="p-1 me-1 preview mt-2 flex-grow-1 text-center w-100 fs-4 fs-lg-3 mt-2" [ngStyle]="{'font-family': item.name, 'word-break': 'keep-all'}">
{{t('brown-fox')}}
</div>
}
}
</ng-template>
</ng-container>

View File

@ -0,0 +1,48 @@
.pill {
font-size: .8rem;
background-color: var(--card-bg-color);
border-radius: 0.375rem;
}
.scroller {
max-height: calc(100dvh - 180px);
overflow-y: auto;
scrollbar-gutter: stable;
@media (max-width: 576px) {
max-height: calc(100dvh - 480px);
}
}
.list-group-item {
background-color: transparent;
&:hover {
background-color: var(--card-bg-color);
}
&.active {
background-color: var(--primary-color);
}
}
.list-group-item, .list-group-item.active {
border-top-width: 0;
border-bottom-width: 0;
}
ngx-file-drop ::ng-deep > div {
// styling for the outer drop box
width: 100%;
border: 2px solid var(--primary-color);
border-radius: 5px;
height: 100px;
margin: auto;
> div {
// styling for the inner box (template)
width: 100%;
display: inline-block;
}
}

View File

@ -0,0 +1,214 @@
import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core';
import {FontService} from "src/app/_services/font.service";
import {AccountService} from "../../../_services/account.service";
import {ConfirmService} from "../../../shared/confirm.service";
import {EpubFont, FontProvider} from 'src/app/_models/preferences/epub-font';
import {NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop";
import {DOCUMENT, NgStyle, NgTemplateOutlet} from "@angular/common";
import {LoadingComponent} from "../../../shared/loading/loading.component";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {SiteThemeProviderPipe} from "../../../_pipes/site-theme-provider.pipe";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {animate, style, transition, trigger} from "@angular/animations";
import {WikiLink} from "../../../_models/wiki";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-font-manager',
imports: [
LoadingComponent,
NgxFileDropModule,
FormsModule,
ReactiveFormsModule,
SentenceCasePipe,
SiteThemeProviderPipe,
NgTemplateOutlet,
TranslocoDirective,
NgStyle,
],
templateUrl: './font-manager.component.html',
styleUrl: './font-manager.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
animations: [
trigger('loadNewFontAnimation', [
transition('void => loaded', [
style({ backgroundColor: 'var(--primary-color)' }),
animate('2s', style({ backgroundColor: 'var(--list-group-item-bg-color)' }))
])
])
],
})
export class FontManagerComponent implements OnInit {
private readonly document = inject(DOCUMENT);
private readonly accountService = inject(AccountService);
private readonly confirmService = inject(ConfirmService);
private readonly toastr = inject(ToastrService);
protected readonly fontService = inject(FontService);
protected readonly FontProvider = FontProvider;
protected readonly WikiLink = WikiLink.EpubFontManager;
protected readonly user = this.accountService.currentUserSignal;
protected readonly isReadOnly = computed(() => {
const u = this.accountService.currentUserSignal();
if (!u) return true;
return this.accountService.hasReadOnlyRole(u);
});
fonts = signal<EpubFont[]>([]);
visibleFonts = computed(() => {
const fonts = this.fonts();
const hide = this.hideSystemFonts();
if (!hide) return fonts;
return fonts.filter(f => f.provider === FontProvider.User);
});
hideSystemFonts = signal(false);
/**
* Fonts added during the current sessions
*/
loadedFonts = signal<EpubFont[]>([]);
selectedFont = signal<EpubFont | undefined>(undefined);
isUploadingFont = signal(false);
uploadMode = signal<'file' | 'url' | 'all'>('all');
form: FormGroup = new FormGroup({
fontUrl: new FormControl('', []),
filter: new FormControl(this.hideSystemFonts(), [])
});
files: NgxFileDropEntry[] = [];
// When accepting more types, also need to update in the Parser
acceptableExtensions = ['.woff2', '.woff', '.ttf', '.otf'].join(',');
ngOnInit() {
this.loadFonts();
}
loadFonts() {
this.fontService.getFonts().subscribe(fonts => {
this.fonts.set(fonts);
// First load, if there are user provided fonts, switch the filter toggle
if (fonts.filter(f => f.provider != FontProvider.System).length > 0 && !this.hideSystemFonts()) {
this.setHideSystemFontsFilter(true);
}
});
}
selectFont(font: EpubFont | undefined) {
if (font === undefined) {
this.selectedFont.set(font);
return;
}
if (font.name !== FontService.DefaultEpubFont) {
this.fontService.getFontFace(font).load().then(loadedFace => {
(this.document as any).fonts.add(loadedFace);
this.selectedFont.set(font);
});
} else {
this.selectedFont.set(font);
}
}
dropped(files: NgxFileDropEntry[]) {
for (const droppedFile of files) {
if (!droppedFile.fileEntry.isFile) {
continue;
}
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
fileEntry.file((file: File) => {
this.fontService.uploadFont(file, droppedFile).subscribe(f => {
this.addFont(f);
this.isUploadingFont.set(false);
});
});
}
this.isUploadingFont.set(true);
}
uploadFromUrl() {
const url = this.form.get('fontUrl')?.value.trim();
if (!url || url === '') return;
this.isUploadingFont.set(true);
this.fontService.uploadFromUrl(url).subscribe((f) => {
this.addFont(f);
this.form.get('fontUrl')!.setValue('');
this.isUploadingFont.set(false);
});
}
async deleteFont(id: number) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-font'))) {
return;
}
// Check if this font is in use
this.fontService.isFontInUse(id).subscribe(async (inUse) => {
if (!inUse) {
this.performDeleteFont(id);
return;
}
const isAdmin = this.accountService.hasAdminRole(this.accountService.currentUserSignal()!);
if (!isAdmin) {
this.toastr.info(translate('toasts.font-in-use'))
return;
}
if (!await this.confirmService.confirm(translate('toasts.confirm-force-delete-font'))) {
return;
}
this.performDeleteFont(id, true);
})
}
private setHideSystemFontsFilter(value: boolean) {
this.hideSystemFonts.set(value);
this.form.get('filter')?.setValue(value);
}
private performDeleteFont(id: number, force: boolean = false) {
this.fontService.deleteFont(id, force).subscribe(() => {
this.fonts.update(x => x.filter(f => f.id !== id));
// Select the first font in the list
const visibleFonts = this.visibleFonts();
if (visibleFonts.length === 0 && this.hideSystemFonts()) {
this.setHideSystemFontsFilter(false);
this.selectFont(this.fonts()[0]); // Default
return;
}
if (visibleFonts.length > 0) {
this.selectFont(visibleFonts[visibleFonts.length - 1]);
}
});
}
private addFont(font: EpubFont) {
this.fonts.update(x => [...x, font]);
this.loadedFonts.update(x => [...x, font]);
setTimeout(() => this.selectedFont.set(font), 100);
}
animationState(font: EpubFont) {
return this.loadedFonts().includes(font) ? 'loaded' : '';
}
protected readonly FontService = FontService;
}

View File

@ -332,8 +332,8 @@
<ng-template #edit>
<select class="form-select" aria-describedby="book-reader-heading"
formControlName="bookReaderFontFamily">
@for (opt of fontFamilies; track opt) {
<option [ngValue]="opt">{{opt | titlecase}}</option>
@for (opt of fonts(); track opt.id) {
<option [ngValue]="opt.name">{{opt.name | titlecase}}</option>
}
</select>
</ng-template>

View File

@ -1,8 +1,18 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit, signal} from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
effect,
inject,
OnInit,
signal
} from '@angular/core';
import {ReadingProfileService} from "../../_services/reading-profile.service";
import {
bookLayoutModes,
bookWritingStyles, breakPoints,
bookWritingStyles,
breakPoints,
layoutModes,
pageSplitOptions,
pdfScrollModes,
@ -19,7 +29,7 @@ import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {User} from "../../_models/user";
import {AccountService} from "../../_services/account.service";
import {debounceTime, distinctUntilChanged, map, take, tap} from "rxjs/operators";
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {BookService} from "../../book-reader/_services/book.service";
@ -41,7 +51,7 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {WritingStylePipe} from "../../_pipes/writing-style.pipe";
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLinkBase, NgbNavOutlet, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {catchError, filter, of, switchMap} from "rxjs";
import {catchError, filter, forkJoin, of, switchMap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {ToastrService} from "ngx-toastr";
@ -53,6 +63,8 @@ import {
} from "../../settings/_components/setting-colour-picker/setting-color-picker.component";
import {ColorscapeService} from "../../_services/colorscape.service";
import {Color} from "@iplab/ngx-color-picker";
import {FontService} from "../../_services/font.service";
import {EpubFont} from "../../_models/preferences/epub-font";
enum TabId {
ImageReader = "image-reader",
@ -108,12 +120,13 @@ export class ManageReadingProfilesComponent implements OnInit {
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
private readonly transLoco = inject(TranslocoService);
private readonly fontService = inject(FontService);
virtualScrollerBreakPoint = 20;
savingProfile = signal(false);
fonts = signal<EpubFont[]>([]);
fontFamilies: Array<string> = [];
readingProfiles: ReadingProfile[] = [];
user!: User;
activeTabId = TabId.ImageReader;
@ -128,18 +141,21 @@ export class ManageReadingProfilesComponent implements OnInit {
});
constructor() {
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
this.cdRef.markForCheck();
}
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
effect(() => {
const user = this.accountService.currentUserSignal();
if (user) {
this.user = user;
}
});
}
ngOnInit(): void {
forkJoin([
this.fontService.getFonts(),
this.readingProfileService.getAllProfiles()
]).subscribe(([fonts, profiles]) => {
this.fonts.set(fonts);
this.readingProfileService.getAllProfiles().subscribe(profiles => {
this.readingProfiles = profiles;
this.loading = false;
this.setupForm();
@ -149,7 +165,6 @@ export class ManageReadingProfilesComponent implements OnInit {
this.cdRef.markForCheck();
});
}
async delete(readingProfile: ReadingProfile) {
@ -175,7 +190,7 @@ export class ManageReadingProfilesComponent implements OnInit {
return (val <= 0) ? '' : val + '%'
}
setupForm() {
async setupForm() {
if (this.selectedProfile == null) {
return;
}
@ -183,8 +198,8 @@ export class ManageReadingProfilesComponent implements OnInit {
this.readingProfileForm = new FormGroup({})
if (this.fontFamilies.indexOf(this.selectedProfile.bookReaderFontFamily) < 0) {
this.selectedProfile.bookReaderFontFamily = 'default';
if (this.fonts().find(font => font.name === this.selectedProfile?.bookReaderFontFamily) === undefined) {
this.selectedProfile.bookReaderFontFamily = FontService.DefaultEpubFont;
}
this.readingProfileForm.addControl('name', new FormControl(this.selectedProfile.name, Validators.required));

View File

@ -46,7 +46,6 @@ export class ManageUserPreferencesComponent implements OnInit {
protected readonly licenseService = inject(LicenseService);
fontFamilies: Array<string> = [];
locales: Array<KavitaLocale> = [];
settingsForm: FormGroup = new FormGroup({});
@ -65,9 +64,6 @@ export class ManageUserPreferencesComponent implements OnInit {
constructor() {
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
this.cdRef.markForCheck();
this.localizationService.getLocales().subscribe(res => {
this.locales = res;

Some files were not shown because too many files have changed in this diff Show More